This commit is contained in:
Laurens Profittlich 2026-06-10 10:13:54 +00:00 committed by GitHub
commit 396e7ed2fb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
128 changed files with 16338 additions and 9212 deletions

4
.gitignore vendored
View File

@ -57,4 +57,6 @@ backend/logs/
backend/uploads/
# Docker 数据
data/
data/
# Deployment secrets (real values, never commit)
deploy/secrets.env

View File

@ -1,29 +1,78 @@
FROM python:3.11
# MiroFish production Dockerfile for agent.profikid.nl deployment
# - Builds the Vue frontend into static files
# - Serves the static bundle via nginx on :3000
# - Reverse-proxies /api/* to the Flask backend on :5001 (same container, localhost)
# - Single exposed port for Traefik
# - Memory graph: Graphiti (Python lib) talks to FalkorDB on the shared network
# 安装 Node.js (满足 >=18及必要工具
FROM python:3.11-slim
ENV DEBIAN_FRONTEND=noninteractive \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
VITE_API_BASE_URL="" \
HF_HUB_OFFLINE=0 \
SENTENCE_TRANSFORMERS_HOME=/root/.cache/huggingface
# Install Node 20 (for building the frontend), nginx (for serving it), and
# build deps for the Python wheels (sentence-transformers pulls numpy/torch).
RUN apt-get update \
&& apt-get install -y --no-install-recommends nodejs npm \
&& rm -rf /var/lib/apt/lists/*
&& apt-get install -y --no-install-recommends \
nodejs \
npm \
nginx \
curl \
ca-certificates \
git \
&& rm -rf /var/lib/apt/lists/*
# 从 uv 官方镜像复制 uv
# Install uv for fast Python package management
COPY --from=ghcr.io/astral-sh/uv:0.9.26 /uv /uvx /bin/
WORKDIR /app
# 先复制依赖描述文件以利用缓存
COPY package.json package-lock.json ./
# ---- 1) Install Python deps (backend) ----
# Copy the local path-dep (camel-oasis fork) BEFORE uv sync, so the path can resolve.
# Note: we don't COPY uv.lock here — the first build has to generate it. Once a
# successful build has happened, the lock will be in the source tree and a
# subsequent `uv sync --frozen` will be reproducible.
COPY backend/vendor ./backend/vendor
COPY backend/pyproject.toml ./backend/
WORKDIR /app/backend
RUN uv sync
# ---- 2) Install Node deps + build frontend ----
WORKDIR /app
# Copy locales first because the Vite build's `@locales` alias points to ../locales
COPY locales/ ./locales/
COPY frontend/package.json frontend/package-lock.json ./frontend/
COPY backend/pyproject.toml backend/uv.lock ./backend/
RUN cd frontend && npm ci --no-audit --no-fund
# 安装依赖Node + Python
RUN npm ci \
&& npm ci --prefix frontend \
&& cd backend && uv sync --frozen
# Copy full frontend source and build the production bundle
COPY frontend/ ./frontend/
RUN cd frontend && npm run build
# 复制项目源码
COPY . .
# ---- 3) Copy the rest of the app (backend source) ----
COPY backend/ ./backend/
EXPOSE 3000 5001
# ---- 4) Pre-download the sentence-transformers embedding model ----
# Doing it at build time means the container is ready to serve on first boot
# (no 100MB download the first time the user runs a graph build).
WORKDIR /app/backend
RUN uv run python -c "from sentence_transformers import SentenceTransformer; \
SentenceTransformer('sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2')" \
|| (echo 'Failed to pre-download embedding model — build will fall back to download on first use.' && exit 0)
# 同时启动前后端(开发模式)
CMD ["npm", "run", "dev"]
# ---- 5) nginx config + start script ----
COPY nginx.conf /etc/nginx/nginx.conf
COPY start.sh /app/start.sh
RUN chmod +x /app/start.sh
# Runtime dirs
RUN mkdir -p /app/backend/uploads /app/logs /var/lib/nginx /var/log/nginx
EXPOSE 3000
CMD ["/app/start.sh"]

View File

@ -28,6 +28,8 @@
**MiroFish** is a next-generation AI prediction engine powered by multi-agent technology. By extracting seed information from the real world (such as breaking news, policy drafts, or financial signals), it automatically constructs a high-fidelity parallel digital world. Within this space, thousands of intelligent agents with independent personalities, long-term memory, and behavioral logic freely interact and undergo social evolution. You can inject variables dynamically from a "God's-eye view" to precisely deduce future trajectories — **rehearse the future in a digital sandbox, and win decisions after countless simulations**.
> **This is a maintained fork** of [666ghj/MiroFish](https://github.com/666ghj/MiroFish). It swaps Zep Cloud for **Graphiti + FalkorDB** (open source, self-hosted), adds a one-call `POST /api/graph/ingest_text` API for cron automations, and ships a production Docker stack for `agent.profikid.nl`. See [About this fork](#-about-this-fork) below.
> You only need to: Upload seed materials (data analysis reports or interesting novel stories) and describe your prediction requirements in natural language</br>
> MiroFish will return: A detailed prediction report and a deeply interactive high-fidelity digital world
@ -116,15 +118,22 @@ cp .env.example .env
```env
# LLM API Configuration (supports any LLM API with OpenAI SDK format)
# Recommended: Alibaba Qwen-plus model via Bailian Platform: https://bailian.console.aliyun.com/
# Recommended: MiniMax M-series via https://api.minimax.io/v1
# - MiniMax-M2.7-highspeed : best for cron / high-volume (recommended)
# - MiniMax-M3 : heavier reasoning, slower, higher quality
# Alibaba Qwen-plus via Bailian Platform: https://bailian.console.aliyun.com/
# High consumption, try simulations with fewer than 40 rounds first
LLM_API_KEY=your_api_key
LLM_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
LLM_MODEL_NAME=qwen-plus
LLM_BASE_URL=https://api.minimax.io/v1
LLM_MODEL_NAME=MiniMax-M2.7-highspeed
# Zep Cloud Configuration
# Free monthly quota is sufficient for simple usage: https://app.getzep.com/
ZEP_API_KEY=your_zep_api_key
# Graph store (this fork: Graphiti + FalkorDB; no Zep API key needed)
# FalkorDB runs as a Docker sidecar — see deploy/docker-compose.yml
FALKORDB_HOST=falkordb
FALKORDB_PORT=6379
# Embedding model (local sentence-transformers, pre-downloaded in the image)
EMBEDDING_MODEL=sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2
```
#### 2. Install Dependencies
@ -192,6 +201,59 @@ The MiroFish team is recruiting full-time/internship positions. If you're intere
MiroFish's simulation engine is powered by **[OASIS (Open Agent Social Interaction Simulations)](https://github.com/camel-ai/oasis)**, We sincerely thank the CAMEL-AI team for their open-source contributions!
## 🔱 About this fork
This is a maintained fork of [666ghj/MiroFish](https://github.com/666ghj/MiroFish) maintained at [profikid/MiroFish](https://github.com/profikid/MiroFish). It replaces the Zep Cloud dependency with **Graphiti + FalkorDB** (both open source), adds a one-call ingest API, and ships a production-ready Docker deployment for `agent.profikid.nl`.
### What changed vs upstream
| | Upstream | This fork |
| --- | --- | --- |
| Graph store | Zep Cloud (managed, requires API key) | Graphiti + FalkorDB (self-hosted, Redis protocol) |
| LLM recommendation | Qwen-plus (DashScope) | MiniMax M-series (M2.7-highspeed for cron, M3 for high-quality) |
| Ingest API | 3 calls (project → ontology → build) | 1 call: `POST /api/graph/ingest_text` (project + ontology + build) |
| Deploy | `npm run dev` (dev) / `docker compose up` (Docker Hub) | `deploy/` overlay: Traefik + Let's Encrypt + FalkorDB sidecar + e2e one-shot |
| E2E test | none shipped | `deploy/e2e_test.py` + `deploy/e2e.sh` (builds a one-shot image, runs against the real briefing seed) |
| Cron-friendly | not designed for it | `deploy/mirofish_ingest.sh` — one-shot POST + poll, exits 0/2/3/4 |
| Embeddings | Zep-managed | local `sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2` (pre-downloaded in the image) |
### Why Graphiti + FalkorDB
Zep Cloud's free tier is fine for dev, but for a self-hosted cron pipeline (Iran briefing every 12h, entity extraction, simulations) you want:
- **No per-month API cap** — the cron keeps running on bad days, good days, news spikes
- **No external service dependency** — graph store lives next to the app as a Docker sidecar
- **Same engine** — Graphiti is the open-source core that powers Zep Cloud, so the entity/edge quality is identical
The `backend/app/services/graphiti_service.py` shim is a Zep-shaped facade over a real `Graphiti(graph_driver=FalkorDriver(...))`, so the rest of the MiroFish codebase (which still speaks Zep) didn't have to change.
### Production deployment
The `deploy/` directory contains a single-host Docker stack for `agent.profikid.nl`:
```bash
# On the host:
cd /docker/mirofish
./deploy/up.sh # build image, bring up mirofish + falkordb
./deploy/e2e.sh # run the e2e test against the real briefing seed
```
Traefik (already on the host) auto-issues the Let's Encrypt cert for `mirofish.agent.profikid.nl`. See [deploy/README.md](./deploy/README.md) for the full layout, env vars, and troubleshooting.
### Cron integration
The fork was built so that a scheduled OSINT briefing cron can drop the markdown output straight into MiroFish:
```bash
# Cron's last-written briefing file -> POST + poll
briefing="$(ls -t /home/hermes/.hermes/cron/output/<job_id>/*.md | head -1)"
nohup bash /docker/mirofish/deploy/mirofish_ingest.sh \
"iran-osint-$(date -u +%Y%m%dT%H%M)" \
"$briefing" \
>/tmp/mirofish-ingest.log 2>&1 &
```
The script handles ontology generation + async graph build + polling, exits 0 on success with a `nodes=N edges=M` summary in the log.
## 📈 Project Statistics
<a href="https://www.star-history.com/#666ghj/MiroFish&type=date&legend=top-left">

View File

@ -1,12 +1,12 @@
"""
MiroFish Backend - Flask应用工厂
MiroFish Backend - Flask application factory.
"""
import os
import warnings
# 抑制 multiprocessing resource_tracker 的警告(来自第三方库如 transformers
# 需要在所有其他导入之前设置
# Suppress multiprocessing resource_tracker warnings (emitted by third-party libs such as transformers).
# Must be configured before any other import.
warnings.filterwarnings("ignore", message=".*resource_tracker.*")
from flask import Flask, request
@ -17,64 +17,64 @@ from .utils.logger import setup_logger, get_logger
def create_app(config_class=Config):
"""Flask应用工厂函数"""
"""Flask application factory."""
app = Flask(__name__)
app.config.from_object(config_class)
# 设置JSON编码确保中文直接显示而不是 \uXXXX 格式)
# Flask >= 2.3 使用 app.json.ensure_ascii旧版本使用 JSON_AS_ASCII 配置
# Configure JSON encoding so Chinese characters are emitted as-is (not escaped to \\uXXXX).
# Flask >= 2.3 uses app.json.ensure_ascii; older versions use the JSON_AS_ASCII config.
if hasattr(app, 'json') and hasattr(app.json, 'ensure_ascii'):
app.json.ensure_ascii = False
# 设置日志
# Set up logging
logger = setup_logger('mirofish')
# 只在 reloader 子进程中打印启动信息(避免 debug 模式下打印两次)
# Only print startup information in the reloader child process (avoids duplicate logs under debug).
is_reloader_process = os.environ.get('WERKZEUG_RUN_MAIN') == 'true'
debug_mode = app.config.get('DEBUG', False)
should_log_startup = not debug_mode or is_reloader_process
if should_log_startup:
logger.info("=" * 50)
logger.info("MiroFish Backend 启动中...")
logger.info("MiroFish Backend starting...")
logger.info("=" * 50)
# 启用CORS
# Enable CORS
CORS(app, resources={r"/api/*": {"origins": "*"}})
# 注册模拟进程清理函数(确保服务器关闭时终止所有模拟进程)
# Register a cleanup hook so that all simulation child processes are terminated
# when the server shuts down.
from .services.simulation_runner import SimulationRunner
SimulationRunner.register_cleanup()
if should_log_startup:
logger.info("已注册模拟进程清理函数")
# 请求日志中间件
logger.info("Registered simulation process cleanup hook")
# Request logging middleware
@app.before_request
def log_request():
logger = get_logger('mirofish.request')
logger.debug(f"请求: {request.method} {request.path}")
logger.debug(f"Request: {request.method} {request.path}")
if request.content_type and 'json' in request.content_type:
logger.debug(f"请求体: {request.get_json(silent=True)}")
logger.debug(f"Request body: {request.get_json(silent=True)}")
@app.after_request
def log_response(response):
logger = get_logger('mirofish.request')
logger.debug(f"响应: {response.status_code}")
logger.debug(f"Response: {response.status_code}")
return response
# 注册蓝图
# Register blueprints
from .api import graph_bp, simulation_bp, report_bp
app.register_blueprint(graph_bp, url_prefix='/api/graph')
app.register_blueprint(simulation_bp, url_prefix='/api/simulation')
app.register_blueprint(report_bp, url_prefix='/api/report')
# 健康检查
# Health check
@app.route('/health')
def health():
return {'status': 'ok', 'service': 'MiroFish Backend'}
if should_log_startup:
logger.info("MiroFish Backend 启动完成")
return app
if should_log_startup:
logger.info("MiroFish Backend startup complete")
return app

View File

@ -1,5 +1,5 @@
"""
API路由模块
API route module.
"""
from flask import Blueprint
@ -11,4 +11,3 @@ report_bp = Blueprint('report', __name__)
from . import graph # noqa: E402, F401
from . import simulation # noqa: E402, F401
from . import report # noqa: E402, F401

View File

@ -1,6 +1,6 @@
"""
图谱相关API路由
采用项目上下文机制服务端持久化状态
Graph API routes.
Uses the project context mechanism so the server persists state between calls.
"""
import os
@ -19,27 +19,27 @@ from ..utils.locale import t, get_locale, set_locale
from ..models.task import TaskManager, TaskStatus
from ..models.project import ProjectManager, ProjectStatus
# 获取日志器
# Get logger
logger = get_logger('mirofish.api')
def allowed_file(filename: str) -> bool:
"""检查文件扩展名是否允许"""
"""Check whether the file extension is in the allow-list."""
if not filename or '.' not in filename:
return False
ext = os.path.splitext(filename)[1].lower().lstrip('.')
return ext in Config.ALLOWED_EXTENSIONS
# ============== 项目管理接口 ==============
# ============== Project management endpoints ==============
@graph_bp.route('/project/<project_id>', methods=['GET'])
def get_project(project_id: str):
"""
获取项目详情
Retrieve project details.
"""
project = ProjectManager.get_project(project_id)
if not project:
return jsonify({
"success": False,
@ -55,11 +55,11 @@ def get_project(project_id: str):
@graph_bp.route('/project/list', methods=['GET'])
def list_projects():
"""
列出所有项目
List all projects.
"""
limit = request.args.get('limit', 50, type=int)
projects = ProjectManager.list_projects(limit=limit)
return jsonify({
"success": True,
"data": [p.to_dict() for p in projects],
@ -70,10 +70,10 @@ def list_projects():
@graph_bp.route('/project/<project_id>', methods=['DELETE'])
def delete_project(project_id: str):
"""
删除项目
Delete a project.
"""
success = ProjectManager.delete_project(project_id)
if not success:
return jsonify({
"success": False,
@ -89,27 +89,27 @@ def delete_project(project_id: str):
@graph_bp.route('/project/<project_id>/reset', methods=['POST'])
def reset_project(project_id: str):
"""
重置项目状态用于重新构建图谱
Reset project state (used to rebuild the graph).
"""
project = ProjectManager.get_project(project_id)
if not project:
return jsonify({
"success": False,
"error": t('api.projectNotFound', id=project_id)
}), 404
# 重置到本体已生成状态
# Roll back to the "ontology generated" state
if project.ontology:
project.status = ProjectStatus.ONTOLOGY_GENERATED
else:
project.status = ProjectStatus.CREATED
project.graph_id = None
project.graph_build_task_id = None
project.error = None
ProjectManager.save_project(project)
return jsonify({
"success": True,
"message": t('api.projectReset', id=project_id),
@ -117,22 +117,22 @@ def reset_project(project_id: str):
})
# ============== 接口1上传文件并生成本体 ==============
# ============== Endpoint 1: upload files and generate the ontology ==============
@graph_bp.route('/ontology/generate', methods=['POST'])
def generate_ontology():
"""
接口1上传文件分析生成本体定义
请求方式multipart/form-data
参数
files: 上传的文件PDF/MD/TXT可多个
simulation_requirement: 模拟需求描述必填
project_name: 项目名称可选
additional_context: 额外说明可选
返回
Endpoint 1: upload files, analyse them and produce an ontology definition.
Request: multipart/form-data.
Parameters:
files: uploaded files (PDF/MD/TXT), one or many.
simulation_requirement: description of the simulation to run (required).
project_name: project name (optional).
additional_context: extra notes (optional).
Returns:
{
"success": true,
"data": {
@ -148,84 +148,84 @@ def generate_ontology():
}
"""
try:
logger.info("=== 开始生成本体定义 ===")
# 获取参数
logger.info("=== Generating ontology definition ===")
# Read parameters
simulation_requirement = request.form.get('simulation_requirement', '')
project_name = request.form.get('project_name', 'Unnamed Project')
additional_context = request.form.get('additional_context', '')
logger.debug(f"项目名称: {project_name}")
logger.debug(f"模拟需求: {simulation_requirement[:100]}...")
logger.debug(f"Project name: {project_name}")
logger.debug(f"Simulation requirement: {simulation_requirement[:100]}...")
if not simulation_requirement:
return jsonify({
"success": False,
"error": t('api.requireSimulationRequirement')
}), 400
# 获取上传的文件
# Read uploaded files
uploaded_files = request.files.getlist('files')
if not uploaded_files or all(not f.filename for f in uploaded_files):
return jsonify({
"success": False,
"error": t('api.requireFileUpload')
}), 400
# 创建项目
# Create the project
project = ProjectManager.create_project(name=project_name)
project.simulation_requirement = simulation_requirement
logger.info(f"创建项目: {project.project_id}")
# 保存文件并提取文本
logger.info(f"Created project: {project.project_id}")
# Save files and extract text
document_texts = []
all_text = ""
for file in uploaded_files:
if file and file.filename and allowed_file(file.filename):
# 保存文件到项目目录
# Save the file inside the project directory
file_info = ProjectManager.save_file_to_project(
project.project_id,
file,
project.project_id,
file,
file.filename
)
project.files.append({
"filename": file_info["original_filename"],
"size": file_info["size"]
})
# 提取文本
# Extract text
text = FileParser.extract_text(file_info["path"])
text = TextProcessor.preprocess_text(text)
document_texts.append(text)
all_text += f"\n\n=== {file_info['original_filename']} ===\n{text}"
if not document_texts:
ProjectManager.delete_project(project.project_id)
return jsonify({
"success": False,
"error": t('api.noDocProcessed')
}), 400
# 保存提取的文本
# Persist the extracted text
project.total_text_length = len(all_text)
ProjectManager.save_extracted_text(project.project_id, all_text)
logger.info(f"文本提取完成,共 {len(all_text)} 字符")
# 生成本体
logger.info("调用 LLM 生成本体定义...")
logger.info(f"Text extraction complete: {len(all_text)} characters")
# Generate the ontology
logger.info("Calling LLM to generate ontology definition...")
generator = OntologyGenerator()
ontology = generator.generate(
document_texts=document_texts,
simulation_requirement=simulation_requirement,
additional_context=additional_context if additional_context else None
)
# 保存本体到项目
# Save the ontology onto the project
entity_count = len(ontology.get("entity_types", []))
edge_count = len(ontology.get("edge_types", []))
logger.info(f"本体生成完成: {entity_count} 个实体类型, {edge_count} 个关系类型")
logger.info(f"Ontology generation complete: {entity_count} entity types, {edge_count} edge types")
project.ontology = {
"entity_types": ontology.get("entity_types", []),
"edge_types": ontology.get("edge_types", [])
@ -233,8 +233,8 @@ def generate_ontology():
project.analysis_summary = ontology.get("analysis_summary", "")
project.status = ProjectStatus.ONTOLOGY_GENERATED
ProjectManager.save_project(project)
logger.info(f"=== 本体生成完成 === 项目ID: {project.project_id}")
logger.info(f"=== Ontology generation complete === Project ID: {project.project_id}")
return jsonify({
"success": True,
"data": {
@ -246,7 +246,7 @@ def generate_ontology():
"total_text_length": project.total_text_length
}
})
except Exception as e:
return jsonify({
"success": False,
@ -255,57 +255,57 @@ def generate_ontology():
}), 500
# ============== 接口2构建图谱 ==============
# ============== Endpoint 2: build the graph ==============
@graph_bp.route('/build', methods=['POST'])
def build_graph():
"""
接口2根据project_id构建图谱
请求JSON
Endpoint 2: build the graph for a given project_id.
Request (JSON):
{
"project_id": "proj_xxxx", // 必填来自接口1
"graph_name": "图谱名称", // 可选
"chunk_size": 500, // 可选默认500
"chunk_overlap": 50 // 可选默认50
"project_id": "proj_xxxx", // required, returned by endpoint 1
"graph_name": "graph name", // optional
"chunk_size": 500, // optional, default 500
"chunk_overlap": 50 // optional, default 50
}
返回
Returns:
{
"success": true,
"data": {
"project_id": "proj_xxxx",
"task_id": "task_xxxx",
"message": "图谱构建任务已启动"
"message": "Graph build task started"
}
}
"""
try:
logger.info("=== 开始构建图谱 ===")
# 检查配置
logger.info("=== Starting graph build ===")
# Validate config
errors = []
if not Config.ZEP_API_KEY:
if not Config.FALKORDB_HOST:
errors.append(t('api.zepApiKeyMissing'))
if errors:
logger.error(f"配置错误: {errors}")
logger.error(f"Configuration error: {errors}")
return jsonify({
"success": False,
"error": t('api.configError', details="; ".join(errors))
}), 500
# 解析请求
# Parse request
data = request.get_json() or {}
project_id = data.get('project_id')
logger.debug(f"请求参数: project_id={project_id}")
logger.debug(f"Request parameters: project_id={project_id}")
if not project_id:
return jsonify({
"success": False,
"error": t('api.requireProjectId')
}), 400
# 获取项目
# Look up the project
project = ProjectManager.get_project(project_id)
if not project:
return jsonify({
@ -313,116 +313,116 @@ def build_graph():
"error": t('api.projectNotFound', id=project_id)
}), 404
# 检查项目状态
force = data.get('force', False) # 强制重新构建
# Check project state
force = data.get('force', False) # force a rebuild
if project.status == ProjectStatus.CREATED:
return jsonify({
"success": False,
"error": t('api.ontologyNotGenerated')
}), 400
if project.status == ProjectStatus.GRAPH_BUILDING and not force:
return jsonify({
"success": False,
"error": t('api.graphBuilding'),
"task_id": project.graph_build_task_id
}), 400
# 如果强制重建,重置状态
# If forced, roll the project back to the "ontology generated" state
if force and project.status in [ProjectStatus.GRAPH_BUILDING, ProjectStatus.FAILED, ProjectStatus.GRAPH_COMPLETED]:
project.status = ProjectStatus.ONTOLOGY_GENERATED
project.graph_id = None
project.graph_build_task_id = None
project.error = None
# 获取配置
# Read configuration
graph_name = data.get('graph_name', project.name or 'MiroFish Graph')
chunk_size = data.get('chunk_size', project.chunk_size or Config.DEFAULT_CHUNK_SIZE)
chunk_overlap = data.get('chunk_overlap', project.chunk_overlap or Config.DEFAULT_CHUNK_OVERLAP)
# 更新项目配置
# Persist configuration on the project
project.chunk_size = chunk_size
project.chunk_overlap = chunk_overlap
# 获取提取的文本
# Read the extracted text
text = ProjectManager.get_extracted_text(project_id)
if not text:
return jsonify({
"success": False,
"error": t('api.textNotFound')
}), 400
# 获取本体
# Read the ontology
ontology = project.ontology
if not ontology:
return jsonify({
"success": False,
"error": t('api.ontologyNotFound')
}), 400
# 创建异步任务
# Create the async task
task_manager = TaskManager()
task_id = task_manager.create_task(f"构建图谱: {graph_name}")
logger.info(f"创建图谱构建任务: task_id={task_id}, project_id={project_id}")
# 更新项目状态
task_id = task_manager.create_task(f"Build graph: {graph_name}")
logger.info(f"Created graph build task: task_id={task_id}, project_id={project_id}")
# Update project state
project.status = ProjectStatus.GRAPH_BUILDING
project.graph_build_task_id = task_id
ProjectManager.save_project(project)
# Capture locale before spawning background thread
current_locale = get_locale()
# 启动后台任务
# Background worker
def build_task():
set_locale(current_locale)
build_logger = get_logger('mirofish.build')
try:
build_logger.info(f"[{task_id}] 开始构建图谱...")
build_logger.info(f"[{task_id}] Starting graph build...")
task_manager.update_task(
task_id,
task_id,
status=TaskStatus.PROCESSING,
message=t('progress.initGraphService')
)
# 创建图谱构建服务
builder = GraphBuilderService(api_key=Config.ZEP_API_KEY)
# 分块
# Create the graph builder service
builder = GraphBuilderService(api_key=None)
# Chunk the text
task_manager.update_task(
task_id,
message=t('progress.textChunking'),
progress=5
)
chunks = TextProcessor.split_text(
text,
chunk_size=chunk_size,
text,
chunk_size=chunk_size,
overlap=chunk_overlap
)
total_chunks = len(chunks)
# 创建图谱
# Create the graph
task_manager.update_task(
task_id,
message=t('progress.creatingZepGraph'),
progress=10
)
graph_id = builder.create_graph(name=graph_name)
# 更新项目的graph_id
# Save the graph_id onto the project
project.graph_id = graph_id
ProjectManager.save_project(project)
# 设置本体
# Apply the ontology
task_manager.update_task(
task_id,
message=t('progress.settingOntology'),
progress=15
)
builder.set_ontology(graph_id, ontology)
# 添加文本progress_callback 签名是 (msg, progress_ratio)
# progress_callback signature: (msg, progress_ratio)
def add_progress_callback(msg, progress_ratio):
progress = 15 + int(progress_ratio * 40) # 15% - 55%
task_manager.update_task(
@ -430,27 +430,27 @@ def build_graph():
message=msg,
progress=progress
)
task_manager.update_task(
task_id,
message=t('progress.addingChunks', count=total_chunks),
progress=15
)
episode_uuids = builder.add_text_batches(
graph_id,
graph_id,
chunks,
batch_size=3,
progress_callback=add_progress_callback
)
# 等待Zep处理完成查询每个episode的processed状态
# Wait for Zep/Graphiti to finish processing (poll each episode's processed flag)
task_manager.update_task(
task_id,
message=t('progress.waitingZepProcess'),
progress=55
)
def wait_progress_callback(msg, progress_ratio):
progress = 55 + int(progress_ratio * 35) # 55% - 90%
task_manager.update_task(
@ -458,26 +458,26 @@ def build_graph():
message=msg,
progress=progress
)
builder._wait_for_episodes(episode_uuids, wait_progress_callback)
# 获取图谱数据
# Fetch the final graph data
task_manager.update_task(
task_id,
message=t('progress.fetchingGraphData'),
progress=95
)
graph_data = builder.get_graph_data(graph_id)
# 更新项目状态
# Update project state
project.status = ProjectStatus.GRAPH_COMPLETED
ProjectManager.save_project(project)
node_count = graph_data.get("node_count", 0)
edge_count = graph_data.get("edge_count", 0)
build_logger.info(f"[{task_id}] 图谱构建完成: graph_id={graph_id}, 节点={node_count}, 边={edge_count}")
# 完成
build_logger.info(f"[{task_id}] Graph build complete: graph_id={graph_id}, nodes={node_count}, edges={edge_count}")
# Mark task as complete
task_manager.update_task(
task_id,
status=TaskStatus.COMPLETED,
@ -491,27 +491,27 @@ def build_graph():
"chunk_count": total_chunks
}
)
except Exception as e:
# 更新项目状态为失败
build_logger.error(f"[{task_id}] 图谱构建失败: {str(e)}")
# Mark project as failed
build_logger.error(f"[{task_id}] Graph build failed: {str(e)}")
build_logger.debug(traceback.format_exc())
project.status = ProjectStatus.FAILED
project.error = str(e)
ProjectManager.save_project(project)
task_manager.update_task(
task_id,
status=TaskStatus.FAILED,
message=t('progress.buildFailed', error=str(e)),
error=traceback.format_exc()
)
# 启动后台线程
# Launch background thread
thread = threading.Thread(target=build_task, daemon=True)
thread.start()
return jsonify({
"success": True,
"data": {
@ -520,7 +520,7 @@ def build_graph():
"message": t('api.graphBuildStarted', taskId=task_id)
}
})
except Exception as e:
return jsonify({
"success": False,
@ -529,21 +529,250 @@ def build_graph():
}), 500
# ============== 任务查询接口 ==============
# ============== One-shot ingest endpoint ==============
import threading
import traceback
from flask import request, jsonify
from . import graph_bp
from ..config import Config
from ..services.graph_builder import GraphBuilderService
from ..services.ontology_generator import OntologyGenerator
from ..services.text_processor import TextProcessor
from ..utils.logger import get_logger
from ..utils.locale import t, get_locale, set_locale
from ..models.task import TaskManager, TaskStatus
from ..models.project import ProjectManager, ProjectStatus
logger = get_logger("mirofish.api.ingest_text")
@graph_bp.route("/ingest_text", methods=["POST"])
def ingest_text():
"""
One-shot ingest: text in, project + ontology + async build out.
Request (JSON):
{
"name": "iran-briefing-2026-06-09T11",
"text": "## IRAN/US/ISRAEL CONFLICT ...",
"simulation_requirement": "Extract entities and relations...",
"additional_context": "optional, free-form",
"graph_name": "optional, defaults to name",
"chunk_size": 500,
"chunk_overlap": 50
}
Response:
{
"success": true,
"data": {
"project_id": "proj_xxxx",
"task_id": "task_xxxx",
"message": "..."
}
}
Poll status with: GET /api/graph/task/<task_id>
"""
try:
data = request.get_json(silent=True) or {}
name = (data.get("name") or "").strip()
text = (data.get("text") or "").strip()
simulation_requirement = (data.get("simulation_requirement") or "").strip()
additional_context = (data.get("additional_context") or "").strip() or None
graph_name = (data.get("graph_name") or name or "MiroFish Graph").strip()
chunk_size = int(data.get("chunk_size") or Config.DEFAULT_CHUNK_SIZE)
chunk_overlap = int(data.get("chunk_overlap") or Config.DEFAULT_CHUNK_OVERLAP)
if not name:
return jsonify({"success": False, "error": "name is required"}), 400
if not text:
return jsonify({"success": False, "error": "text is required"}), 400
if not simulation_requirement:
return jsonify(
{"success": False, "error": "simulation_requirement is required"}
), 400
if not Config.FALKORDB_HOST:
return jsonify({"success": False, "error": "FalkorDB not configured"}), 500
# 1. Create project
project = ProjectManager.create_project(name=name)
project.simulation_requirement = simulation_requirement
project.chunk_size = chunk_size
project.chunk_overlap = chunk_overlap
logger.info(f"[ingest_text] created project {project.project_id} ({name})")
# 2. Save extracted text
cleaned_text = TextProcessor.preprocess_text(text)
project.total_text_length = len(cleaned_text)
ProjectManager.save_extracted_text(project.project_id, cleaned_text)
logger.info(
f"[ingest_text] saved text: {len(cleaned_text)} chars "
f"(project={project.project_id})"
)
# 3. Generate ontology synchronously — this is an LLM call, takes ~1030s
generator = OntologyGenerator()
ontology = generator.generate(
document_texts=[cleaned_text],
simulation_requirement=simulation_requirement,
additional_context=additional_context,
)
entity_count = len(ontology.get("entity_types", []))
edge_count = len(ontology.get("edge_types", []))
project.ontology = {
"entity_types": ontology.get("entity_types", []),
"edge_types": ontology.get("edge_types", []),
}
project.analysis_summary = ontology.get("analysis_summary", "")
project.status = ProjectStatus.ONTOLOGY_GENERATED
ProjectManager.save_project(project)
logger.info(
f"[ingest_text] ontology ready: {entity_count} entity types, "
f"{edge_count} edge types (project={project.project_id})"
)
# 4. Kick off async graph build (same path /build uses)
task_manager = TaskManager()
task_id = task_manager.create_task(f"Build graph: {graph_name}")
project.status = ProjectStatus.GRAPH_BUILDING
project.graph_build_task_id = task_id
ProjectManager.save_project(project)
current_locale = get_locale()
def build_task():
set_locale(current_locale)
build_logger = get_logger("mirofish.build.ingest")
try:
build_logger.info(
f"[{task_id}] start build for project={project.project_id}"
)
task_manager.update_task(
task_id, status=TaskStatus.PROCESSING, message=t("progress.initGraphService")
)
builder = GraphBuilderService(api_key=None)
# Chunk
task_manager.update_task(task_id, message=t("progress.textChunking"), progress=5)
chunks = TextProcessor.split_text(
cleaned_text, chunk_size=chunk_size, overlap=chunk_overlap
)
total_chunks = len(chunks)
# Create graph + ontology
task_manager.update_task(
task_id, message=t("progress.creatingZepGraph"), progress=10
)
graph_id = builder.create_graph(name=graph_name)
project.graph_id = graph_id
ProjectManager.save_project(project)
task_manager.update_task(
task_id, message=t("progress.settingOntology"), progress=15
)
builder.set_ontology(graph_id, ontology)
# Add text batches
def add_cb(msg, ratio):
task_manager.update_task(
task_id, message=msg, progress=15 + int(ratio * 40)
)
episode_uuids = builder.add_text_batches(
graph_id, chunks, batch_size=3, progress_callback=add_cb
)
# Wait for Graphiti to process episodes
def wait_cb(msg, ratio):
task_manager.update_task(
task_id, message=msg, progress=55 + int(ratio * 35)
)
builder._wait_for_episodes(episode_uuids, wait_cb)
# Fetch final graph data
graph_data = builder.get_graph_data(graph_id)
project.status = ProjectStatus.GRAPH_COMPLETED
ProjectManager.save_project(project)
node_count = graph_data.get("node_count", 0)
edge_count_out = graph_data.get("edge_count", 0)
build_logger.info(
f"[{task_id}] done: graph_id={graph_id} "
f"nodes={node_count} edges={edge_count_out}"
)
task_manager.update_task(
task_id,
status=TaskStatus.COMPLETED,
message=t("progress.graphBuildComplete"),
progress=100,
result={
"project_id": project.project_id,
"graph_id": graph_id,
"node_count": node_count,
"edge_count": edge_count_out,
"chunk_count": total_chunks,
},
)
except Exception as e:
build_logger.error(f"[{task_id}] failed: {e}")
build_logger.debug(traceback.format_exc())
project.status = ProjectStatus.FAILED
project.error = str(e)
ProjectManager.save_project(project)
task_manager.update_task(
task_id,
status=TaskStatus.FAILED,
message=t("progress.buildFailed", error=str(e)),
error=traceback.format_exc(),
)
thread = threading.Thread(target=build_task, daemon=True)
thread.start()
return jsonify(
{
"success": True,
"data": {
"project_id": project.project_id,
"task_id": task_id,
"graph_name": graph_name,
"ontology_entity_types": entity_count,
"ontology_edge_types": edge_count,
"text_length": len(cleaned_text),
"message": t("api.graphBuildStarted", taskId=task_id),
},
}
)
except Exception as e:
logger.error(f"ingest_text failed: {e}")
logger.debug(traceback.format_exc())
return jsonify(
{"success": False, "error": str(e), "traceback": traceback.format_exc()}
), 500
# ============== Task query endpoints ==============
@graph_bp.route('/task/<task_id>', methods=['GET'])
def get_task(task_id: str):
"""
查询任务状态
Query a task's state.
"""
task = TaskManager().get_task(task_id)
if not task:
return jsonify({
"success": False,
"error": t('api.taskNotFound', id=task_id)
}), 404
return jsonify({
"success": True,
"data": task.to_dict()
@ -553,10 +782,10 @@ def get_task(task_id: str):
@graph_bp.route('/tasks', methods=['GET'])
def list_tasks():
"""
列出所有任务
List all tasks.
"""
tasks = TaskManager().list_tasks()
return jsonify({
"success": True,
"data": [t.to_dict() for t in tasks],
@ -564,28 +793,28 @@ def list_tasks():
})
# ============== 图谱数据接口 ==============
# ============== Graph data endpoints ==============
@graph_bp.route('/data/<graph_id>', methods=['GET'])
def get_graph_data(graph_id: str):
"""
获取图谱数据节点和边
Get graph data (nodes and edges).
"""
try:
if not Config.ZEP_API_KEY:
if not Config.FALKORDB_HOST:
return jsonify({
"success": False,
"error": t('api.zepApiKeyMissing')
}), 500
builder = GraphBuilderService(api_key=Config.ZEP_API_KEY)
builder = GraphBuilderService(api_key=None)
graph_data = builder.get_graph_data(graph_id)
return jsonify({
"success": True,
"data": graph_data
})
except Exception as e:
return jsonify({
"success": False,
@ -597,23 +826,23 @@ def get_graph_data(graph_id: str):
@graph_bp.route('/delete/<graph_id>', methods=['DELETE'])
def delete_graph(graph_id: str):
"""
删除Zep图谱
Delete a Zep/Graphiti graph.
"""
try:
if not Config.ZEP_API_KEY:
if not Config.FALKORDB_HOST:
return jsonify({
"success": False,
"error": t('api.zepApiKeyMissing')
}), 500
builder = GraphBuilderService(api_key=Config.ZEP_API_KEY)
builder = GraphBuilderService(api_key=None)
builder.delete_graph(graph_id)
return jsonify({
"success": True,
"message": t('api.graphDeleted', id=graph_id)
})
except Exception as e:
return jsonify({
"success": False,

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,54 +1,64 @@
"""
配置管理
统一从项目根目录的 .env 文件加载配置
Configuration management.
Loads configuration from the ``.env`` file at the project root.
"""
import os
from dotenv import load_dotenv
# 加载项目根目录的 .env 文件
# 路径: MiroFish/.env (相对于 backend/app/config.py)
# Load the project-root .env file.
# Path: MiroFish/.env (relative to backend/app/config.py)
project_root_env = os.path.join(os.path.dirname(__file__), '../../.env')
if os.path.exists(project_root_env):
load_dotenv(project_root_env, override=True)
else:
# 如果根目录没有 .env尝试加载环境变量用于生产环境
# If no .env at the root, fall back to environment variables (production-style).
load_dotenv(override=True)
class Config:
"""Flask配置类"""
# Flask配置
"""Flask configuration class."""
# Flask configuration
SECRET_KEY = os.environ.get('SECRET_KEY', 'mirofish-secret-key')
DEBUG = os.environ.get('FLASK_DEBUG', 'True').lower() == 'true'
# JSON配置 - 禁用ASCII转义让中文直接显示而不是 \uXXXX 格式)
# JSON configuration - disable ASCII escaping so Chinese characters are emitted
# directly instead of being encoded as \\uXXXX.
JSON_AS_ASCII = False
# LLM配置统一使用OpenAI格式
# LLM configuration (uniformly using the OpenAI-compatible interface)
LLM_API_KEY = os.environ.get('LLM_API_KEY')
LLM_BASE_URL = os.environ.get('LLM_BASE_URL', 'https://api.openai.com/v1')
LLM_MODEL_NAME = os.environ.get('LLM_MODEL_NAME', 'gpt-4o-mini')
# Zep配置
ZEP_API_KEY = os.environ.get('ZEP_API_KEY')
# 文件上传配置
# FalkorDB configuration (replaces Zep)
FALKORDB_HOST = os.environ.get('FALKORDB_HOST', 'localhost')
FALKORDB_PORT = int(os.environ.get('FALKORDB_PORT', '6379'))
FALKORDB_USERNAME = os.environ.get('FALKORDB_USERNAME', None) or None
FALKORDB_PASSWORD = os.environ.get('FALKORDB_PASSWORD', None) or None
# Embedding model: local sentence-transformers (pre-downloaded in image)
EMBEDDING_MODEL = os.environ.get(
'EMBEDDING_MODEL',
'sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2',
)
# File upload configuration
MAX_CONTENT_LENGTH = 50 * 1024 * 1024 # 50MB
UPLOAD_FOLDER = os.path.join(os.path.dirname(__file__), '../uploads')
ALLOWED_EXTENSIONS = {'pdf', 'md', 'txt', 'markdown'}
# 文本处理配置
DEFAULT_CHUNK_SIZE = 500 # 默认切块大小
DEFAULT_CHUNK_OVERLAP = 50 # 默认重叠大小
# OASIS模拟配置
# Text processing configuration
DEFAULT_CHUNK_SIZE = 500 # default chunk size
DEFAULT_CHUNK_OVERLAP = 50 # default overlap
# OASIS simulation configuration
OASIS_DEFAULT_MAX_ROUNDS = int(os.environ.get('OASIS_DEFAULT_MAX_ROUNDS', '10'))
OASIS_SIMULATION_DATA_DIR = os.path.join(os.path.dirname(__file__), '../uploads/simulations')
# OASIS平台可用动作配置
# OASIS platform available actions
OASIS_TWITTER_ACTIONS = [
'CREATE_POST', 'LIKE_POST', 'REPOST', 'FOLLOW', 'DO_NOTHING', 'QUOTE_POST'
]
@ -57,19 +67,19 @@ class Config:
'LIKE_COMMENT', 'DISLIKE_COMMENT', 'SEARCH_POSTS', 'SEARCH_USER',
'TREND', 'REFRESH', 'DO_NOTHING', 'FOLLOW', 'MUTE'
]
# Report Agent配置
# Report Agent configuration
REPORT_AGENT_MAX_TOOL_CALLS = int(os.environ.get('REPORT_AGENT_MAX_TOOL_CALLS', '5'))
REPORT_AGENT_MAX_REFLECTION_ROUNDS = int(os.environ.get('REPORT_AGENT_MAX_REFLECTION_ROUNDS', '2'))
REPORT_AGENT_TEMPERATURE = float(os.environ.get('REPORT_AGENT_TEMPERATURE', '0.5'))
@classmethod
def validate(cls) -> list[str]:
"""验证必要配置"""
"""Validate required configuration."""
errors: list[str] = []
if not cls.LLM_API_KEY:
errors.append("LLM_API_KEY 未配置")
if not cls.ZEP_API_KEY:
errors.append("ZEP_API_KEY 未配置")
errors.append("LLM_API_KEY is not configured")
# FalkorDB host defaults to localhost; only fail if explicitly empty
if not cls.FALKORDB_HOST:
errors.append("FALKORDB_HOST is not configured")
return errors

View File

@ -1,9 +1,8 @@
"""
数据模型模块
Data models module.
"""
from .task import TaskManager, TaskStatus
from .project import Project, ProjectStatus, ProjectManager
__all__ = ['TaskManager', 'TaskStatus', 'Project', 'ProjectStatus', 'ProjectManager']

View File

@ -1,6 +1,7 @@
"""
项目上下文管理
用于在服务端持久化项目状态避免前端在接口间传递大量数据
Project context management.
Persists project state on the server side so the frontend does not need to
shuttle large amounts of data between API calls.
"""
import os
@ -15,45 +16,45 @@ from ..config import Config
class ProjectStatus(str, Enum):
"""项目状态"""
CREATED = "created" # 刚创建,文件已上传
ONTOLOGY_GENERATED = "ontology_generated" # 本体已生成
GRAPH_BUILDING = "graph_building" # 图谱构建中
GRAPH_COMPLETED = "graph_completed" # 图谱构建完成
FAILED = "failed" # 失败
"""Project lifecycle status."""
CREATED = "created" # just created, files uploaded
ONTOLOGY_GENERATED = "ontology_generated" # ontology has been generated
GRAPH_BUILDING = "graph_building" # graph is being built
GRAPH_COMPLETED = "graph_completed" # graph build complete
FAILED = "failed" # failed
@dataclass
class Project:
"""项目数据模型"""
"""Project data model."""
project_id: str
name: str
status: ProjectStatus
created_at: str
updated_at: str
# 文件信息
# File metadata
files: List[Dict[str, str]] = field(default_factory=list) # [{filename, path, size}]
total_text_length: int = 0
# 本体信息接口1生成后填充
# Ontology info (populated after endpoint 1)
ontology: Optional[Dict[str, Any]] = None
analysis_summary: Optional[str] = None
# 图谱信息接口2完成后填充
# Graph info (populated after endpoint 2 completes)
graph_id: Optional[str] = None
graph_build_task_id: Optional[str] = None
# 配置
# Configuration
simulation_requirement: Optional[str] = None
chunk_size: int = 500
chunk_overlap: int = 50
# 错误信息
# Error information
error: Optional[str] = None
def to_dict(self) -> Dict[str, Any]:
"""转换为字典"""
"""Convert to a plain dictionary."""
return {
"project_id": self.project_id,
"name": self.name,
@ -71,14 +72,14 @@ class Project:
"chunk_overlap": self.chunk_overlap,
"error": self.error
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'Project':
"""从字典创建"""
"""Create a Project from a plain dictionary."""
status = data.get('status', 'created')
if isinstance(status, str):
status = ProjectStatus(status)
return cls(
project_id=data['project_id'],
name=data.get('name', 'Unnamed Project'),
@ -99,52 +100,52 @@ class Project:
class ProjectManager:
"""项目管理器 - 负责项目的持久化存储和检索"""
# 项目存储根目录
"""Project manager - responsible for persistent storage and retrieval of projects."""
# Project storage root directory
PROJECTS_DIR = os.path.join(Config.UPLOAD_FOLDER, 'projects')
@classmethod
def _ensure_projects_dir(cls):
"""确保项目目录存在"""
"""Make sure the projects directory exists."""
os.makedirs(cls.PROJECTS_DIR, exist_ok=True)
@classmethod
def _get_project_dir(cls, project_id: str) -> str:
"""获取项目目录路径"""
"""Get the directory path for a project."""
return os.path.join(cls.PROJECTS_DIR, project_id)
@classmethod
def _get_project_meta_path(cls, project_id: str) -> str:
"""获取项目元数据文件路径"""
"""Get the path to a project's metadata file."""
return os.path.join(cls._get_project_dir(project_id), 'project.json')
@classmethod
def _get_project_files_dir(cls, project_id: str) -> str:
"""获取项目文件存储目录"""
"""Get the directory where a project's files are stored."""
return os.path.join(cls._get_project_dir(project_id), 'files')
@classmethod
def _get_project_text_path(cls, project_id: str) -> str:
"""获取项目提取文本存储路径"""
"""Get the path to a project's extracted text."""
return os.path.join(cls._get_project_dir(project_id), 'extracted_text.txt')
@classmethod
def create_project(cls, name: str = "Unnamed Project") -> Project:
"""
创建新项目
Create a new project.
Args:
name: 项目名称
name: project name.
Returns:
新创建的Project对象
The newly created Project object.
"""
cls._ensure_projects_dir()
project_id = f"proj_{uuid.uuid4().hex[:12]}"
now = datetime.now().isoformat()
project = Project(
project_id=project_id,
name=name,
@ -152,154 +153,153 @@ class ProjectManager:
created_at=now,
updated_at=now
)
# 创建项目目录结构
# Create the project directory structure
project_dir = cls._get_project_dir(project_id)
files_dir = cls._get_project_files_dir(project_id)
os.makedirs(project_dir, exist_ok=True)
os.makedirs(files_dir, exist_ok=True)
# 保存项目元数据
# Persist the project metadata
cls.save_project(project)
return project
@classmethod
def save_project(cls, project: Project) -> None:
"""保存项目元数据"""
"""Persist project metadata to disk."""
project.updated_at = datetime.now().isoformat()
meta_path = cls._get_project_meta_path(project.project_id)
with open(meta_path, 'w', encoding='utf-8') as f:
json.dump(project.to_dict(), f, ensure_ascii=False, indent=2)
@classmethod
def get_project(cls, project_id: str) -> Optional[Project]:
"""
获取项目
Retrieve a project by ID.
Args:
project_id: 项目ID
project_id: project ID.
Returns:
Project对象如果不存在返回None
The Project object, or None if it does not exist.
"""
meta_path = cls._get_project_meta_path(project_id)
if not os.path.exists(meta_path):
return None
with open(meta_path, 'r', encoding='utf-8') as f:
data = json.load(f)
return Project.from_dict(data)
@classmethod
def list_projects(cls, limit: int = 50) -> List[Project]:
"""
列出所有项目
List all projects.
Args:
limit: 返回数量限制
limit: maximum number of results to return.
Returns:
项目列表按创建时间倒序
A list of projects, sorted newest-first by creation time.
"""
cls._ensure_projects_dir()
projects = []
for project_id in os.listdir(cls.PROJECTS_DIR):
project = cls.get_project(project_id)
if project:
projects.append(project)
# 按创建时间倒序排序
# Sort newest-first by creation time
projects.sort(key=lambda p: p.created_at, reverse=True)
return projects[:limit]
@classmethod
def delete_project(cls, project_id: str) -> bool:
"""
删除项目及其所有文件
Delete a project and all of its files.
Args:
project_id: 项目ID
project_id: project ID.
Returns:
是否删除成功
True if the project was deleted, False otherwise.
"""
project_dir = cls._get_project_dir(project_id)
if not os.path.exists(project_dir):
return False
shutil.rmtree(project_dir)
return True
@classmethod
def save_file_to_project(cls, project_id: str, file_storage, original_filename: str) -> Dict[str, str]:
"""
保存上传的文件到项目目录
Save an uploaded file into the project's directory.
Args:
project_id: 项目ID
file_storage: Flask的FileStorage对象
original_filename: 原始文件名
project_id: project ID.
file_storage: Flask FileStorage object.
original_filename: original filename.
Returns:
文件信息字典 {filename, path, size}
Dictionary with file metadata: {filename, path, size}.
"""
files_dir = cls._get_project_files_dir(project_id)
os.makedirs(files_dir, exist_ok=True)
# 生成安全的文件名
# Build a safe filename
ext = os.path.splitext(original_filename)[1].lower()
safe_filename = f"{uuid.uuid4().hex[:8]}{ext}"
file_path = os.path.join(files_dir, safe_filename)
# 保存文件
# Save the file
file_storage.save(file_path)
# 获取文件大小
# Read back its size
file_size = os.path.getsize(file_path)
return {
"original_filename": original_filename,
"saved_filename": safe_filename,
"path": file_path,
"size": file_size
}
@classmethod
def save_extracted_text(cls, project_id: str, text: str) -> None:
"""保存提取的文本"""
"""Persist extracted text to disk."""
text_path = cls._get_project_text_path(project_id)
with open(text_path, 'w', encoding='utf-8') as f:
f.write(text)
@classmethod
def get_extracted_text(cls, project_id: str) -> Optional[str]:
"""获取提取的文本"""
"""Read extracted text from disk."""
text_path = cls._get_project_text_path(project_id)
if not os.path.exists(text_path):
return None
with open(text_path, 'r', encoding='utf-8') as f:
return f.read()
@classmethod
def get_project_files(cls, project_id: str) -> List[str]:
"""获取项目的所有文件路径"""
"""Return the absolute paths of all files in the project."""
files_dir = cls._get_project_files_dir(project_id)
if not os.path.exists(files_dir):
return []
return [
os.path.join(files_dir, f)
for f in os.listdir(files_dir)
os.path.join(files_dir, f)
for f in os.listdir(files_dir)
if os.path.isfile(os.path.join(files_dir, f))
]

View File

@ -1,6 +1,6 @@
"""
任务状态管理
用于跟踪长时间运行的任务如图谱构建
Task status management.
Tracks long-running tasks (such as graph building) and their progress.
"""
import uuid
@ -14,30 +14,30 @@ from ..utils.locale import t
class TaskStatus(str, Enum):
"""任务状态枚举"""
PENDING = "pending" # 等待中
PROCESSING = "processing" # 处理中
COMPLETED = "completed" # 已完成
FAILED = "failed" # 失败
"""Task status enum."""
PENDING = "pending" # waiting
PROCESSING = "processing" # in progress
COMPLETED = "completed" # finished
FAILED = "failed" # failed
@dataclass
class Task:
"""任务数据类"""
"""Task data class."""
task_id: str
task_type: str
status: TaskStatus
created_at: datetime
updated_at: datetime
progress: int = 0 # 总进度百分比 0-100
message: str = "" # 状态消息
result: Optional[Dict] = None # 任务结果
error: Optional[str] = None # 错误信息
metadata: Dict = field(default_factory=dict) # 额外元数据
progress_detail: Dict = field(default_factory=dict) # 详细进度信息
progress: int = 0 # overall progress percentage 0-100
message: str = "" # status message
result: Optional[Dict] = None # task result payload
error: Optional[str] = None # error message
metadata: Dict = field(default_factory=dict) # extra metadata
progress_detail: Dict = field(default_factory=dict) # detailed progress
def to_dict(self) -> Dict[str, Any]:
"""转换为字典"""
"""Convert to a plain dictionary."""
return {
"task_id": self.task_id,
"task_type": self.task_type,
@ -55,15 +55,15 @@ class Task:
class TaskManager:
"""
任务管理器
线程安全的任务状态管理
Task manager.
Thread-safe tracking of task state and progress.
"""
_instance = None
_lock = threading.Lock()
def __new__(cls):
"""单例模式"""
"""Singleton accessor."""
if cls._instance is None:
with cls._lock:
if cls._instance is None:
@ -71,21 +71,21 @@ class TaskManager:
cls._instance._tasks: Dict[str, Task] = {}
cls._instance._task_lock = threading.Lock()
return cls._instance
def create_task(self, task_type: str, metadata: Optional[Dict] = None) -> str:
"""
创建新任务
Create a new task.
Args:
task_type: 任务类型
metadata: 额外元数据
task_type: task type.
metadata: extra metadata.
Returns:
任务ID
The new task ID.
"""
task_id = str(uuid.uuid4())
now = datetime.now()
task = Task(
task_id=task_id,
task_type=task_type,
@ -94,17 +94,17 @@ class TaskManager:
updated_at=now,
metadata=metadata or {}
)
with self._task_lock:
self._tasks[task_id] = task
return task_id
def get_task(self, task_id: str) -> Optional[Task]:
"""获取任务"""
"""Fetch a task by ID."""
with self._task_lock:
return self._tasks.get(task_id)
def update_task(
self,
task_id: str,
@ -116,16 +116,16 @@ class TaskManager:
progress_detail: Optional[Dict] = None
):
"""
更新任务状态
Update task state.
Args:
task_id: 任务ID
status: 新状态
progress: 进度
message: 消息
result: 结果
error: 错误信息
progress_detail: 详细进度信息
task_id: task ID.
status: new status.
progress: progress percentage.
message: status message.
result: result payload.
error: error message.
progress_detail: detailed progress information.
"""
with self._task_lock:
task = self._tasks.get(task_id)
@ -143,9 +143,9 @@ class TaskManager:
task.error = error
if progress_detail is not None:
task.progress_detail = progress_detail
def complete_task(self, task_id: str, result: Dict):
"""标记任务完成"""
"""Mark a task as completed."""
self.update_task(
task_id,
status=TaskStatus.COMPLETED,
@ -153,29 +153,29 @@ class TaskManager:
message=t('progress.taskComplete'),
result=result
)
def fail_task(self, task_id: str, error: str):
"""标记任务失败"""
"""Mark a task as failed."""
self.update_task(
task_id,
status=TaskStatus.FAILED,
message=t('progress.taskFailed'),
error=error
)
def list_tasks(self, task_type: Optional[str] = None) -> list:
"""列出任务"""
"""List tasks, optionally filtered by type."""
with self._task_lock:
tasks = list(self._tasks.values())
if task_type:
tasks = [t for t in tasks if t.task_type == task_type]
return [t.to_dict() for t in sorted(tasks, key=lambda x: x.created_at, reverse=True)]
def cleanup_old_tasks(self, max_age_hours: int = 24):
"""清理旧任务"""
"""Remove tasks older than the given threshold."""
from datetime import timedelta
cutoff = datetime.now() - timedelta(hours=max_age_hours)
with self._task_lock:
old_ids = [
tid for tid, task in self._tasks.items()
@ -183,4 +183,3 @@ class TaskManager:
]
for tid in old_ids:
del self._tasks[tid]

View File

@ -1,5 +1,5 @@
"""
业务服务模块
Business services module.
"""
from .ontology_generator import OntologyGenerator
@ -9,7 +9,7 @@ from .zep_entity_reader import ZepEntityReader, EntityNode, FilteredEntities
from .oasis_profile_generator import OasisProfileGenerator, OasisAgentProfile
from .simulation_manager import SimulationManager, SimulationState, SimulationStatus
from .simulation_config_generator import (
SimulationConfigGenerator,
SimulationConfigGenerator,
SimulationParameters,
AgentActivityConfig,
TimeSimulationConfig,
@ -38,8 +38,8 @@ from .simulation_ipc import (
)
__all__ = [
'OntologyGenerator',
'GraphBuilderService',
'OntologyGenerator',
'GraphBuilderService',
'TextProcessor',
'ZepEntityReader',
'EntityNode',
@ -70,4 +70,3 @@ __all__ = [
'CommandType',
'CommandStatus',
]

View File

@ -1,6 +1,6 @@
"""
图谱构建服务
接口2使用Zep API构建Standalone Graph
Graph build service
Interface 2: build a Standalone Graph via the Zep API.
"""
import os
@ -10,24 +10,42 @@ import threading
from typing import Dict, Any, List, Optional, Callable
from dataclasses import dataclass
from zep_cloud.client import Zep
from zep_cloud import EpisodeData, EntityEdgeSourceTarget
# Zep types (EpisodeData, EntityEdgeSourceTarget, EntityModel/EdgeModel from
# external_clients.ontology) are still referenced for ontology dynamic Pydantic
# classes. When zep-cloud is uninstalled, we provide stubs so module import
# succeeds and the Zep `client` attribute on this service is just the
# GraphitiAdapter (see __init__ below).
try:
from zep_cloud.client import Zep
from zep_cloud import EpisodeData, EntityEdgeSourceTarget
except ImportError:
class Zep:
def __init__(self, *a, **kw): pass
class EpisodeData:
def __init__(self, data, type="text"):
self.data = data
self.type = type
class EntityEdgeSourceTarget:
def __init__(self, source, target):
self.source = source
self.target = target
from ..config import Config
from ..models.task import TaskManager, TaskStatus
from ..utils.zep_paging import fetch_all_nodes, fetch_all_edges
from .text_processor import TextProcessor
from .graphiti_service import get_graphiti_adapter
from ..utils.locale import t, get_locale, set_locale
@dataclass
class GraphInfo:
"""图谱信息"""
"""Graph info"""
graph_id: str
node_count: int
edge_count: int
entity_types: List[str]
def to_dict(self) -> Dict[str, Any]:
return {
"graph_id": self.graph_id,
@ -39,18 +57,17 @@ class GraphInfo:
class GraphBuilderService:
"""
图谱构建服务
负责调用Zep API构建知识图谱
Graph build service
Responsible for calling the Zep API to build the knowledge graph.
"""
def __init__(self, api_key: Optional[str] = None):
self.api_key = api_key or Config.ZEP_API_KEY
if not self.api_key:
raise ValueError("ZEP_API_KEY 未配置")
self.client = Zep(api_key=self.api_key)
# Zep's per-project graph becomes Graphiti's group_id (a partition).
# The single shared GraphitiAdapter handles all projects.
self.api_key = api_key # kept for signature compat; no longer required
self.client = get_graphiti_adapter()
self.task_manager = TaskManager()
def build_graph_async(
self,
text: str,
@ -61,20 +78,20 @@ class GraphBuilderService:
batch_size: int = 3
) -> str:
"""
异步构建图谱
Build the graph asynchronously.
Args:
text: 输入文本
ontology: 本体定义来自接口1的输出
graph_name: 图谱名称
chunk_size: 文本块大小
chunk_overlap: 块重叠大小
batch_size: 每批发送的块数量
text: Input text
ontology: Ontology definition (output of interface 1)
graph_name: Graph name
chunk_size: Text chunk size
chunk_overlap: Chunk overlap size
batch_size: Number of chunks sent per batch
Returns:
任务ID
Task ID
"""
# 创建任务
# Create task
task_id = self.task_manager.create_task(
task_type="graph_build",
metadata={
@ -83,20 +100,20 @@ class GraphBuilderService:
"text_length": len(text),
}
)
# Capture locale before spawning background thread
current_locale = get_locale()
# 在后台线程中执行构建
# Execute the build in a background thread
thread = threading.Thread(
target=self._build_graph_worker,
args=(task_id, text, ontology, graph_name, chunk_size, chunk_overlap, batch_size, current_locale)
)
thread.daemon = True
thread.start()
return task_id
def _build_graph_worker(
self,
task_id: str,
@ -108,7 +125,7 @@ class GraphBuilderService:
batch_size: int,
locale: str = 'zh'
):
"""图谱构建工作线程"""
"""Graph build worker thread"""
set_locale(locale)
try:
self.task_manager.update_task(
@ -117,24 +134,24 @@ class GraphBuilderService:
progress=5,
message=t('progress.startBuildingGraph')
)
# 1. 创建图谱
# 1. Create the graph
graph_id = self.create_graph(graph_name)
self.task_manager.update_task(
task_id,
progress=10,
message=t('progress.graphCreated', graphId=graph_id)
)
# 2. 设置本体
# 2. Set the ontology
self.set_ontology(graph_id, ontology)
self.task_manager.update_task(
task_id,
progress=15,
message=t('progress.ontologySet')
)
# 3. 文本分块
# 3. Split the text into chunks
chunks = TextProcessor.split_text(text, chunk_size, chunk_overlap)
total_chunks = len(chunks)
self.task_manager.update_task(
@ -142,8 +159,8 @@ class GraphBuilderService:
progress=20,
message=t('progress.textSplit', count=total_chunks)
)
# 4. 分批发送数据
# 4. Send data in batches
episode_uuids = self.add_text_batches(
graph_id, chunks, batch_size,
lambda msg, prog: self.task_manager.update_task(
@ -152,14 +169,14 @@ class GraphBuilderService:
message=msg
)
)
# 5. 等待Zep处理完成
# 5. Wait for Zep processing to finish
self.task_manager.update_task(
task_id,
progress=60,
message=t('progress.waitingZepProcess')
)
self._wait_for_episodes(
episode_uuids,
lambda msg, prog: self.task_manager.update_task(
@ -168,109 +185,127 @@ class GraphBuilderService:
message=msg
)
)
# 6. 获取图谱信息
# 6. Fetch graph info
self.task_manager.update_task(
task_id,
progress=90,
message=t('progress.fetchingGraphInfo')
)
graph_info = self._get_graph_info(graph_id)
# 完成
# Complete
self.task_manager.complete_task(task_id, {
"graph_id": graph_id,
"graph_info": graph_info.to_dict(),
"chunks_processed": total_chunks,
})
except Exception as e:
import traceback
error_msg = f"{str(e)}\n{traceback.format_exc()}"
self.task_manager.fail_task(task_id, error_msg)
def create_graph(self, name: str) -> str:
"""创建Zep图谱公开方法"""
"""Create a graph (public method)"""
graph_id = f"mirofish_{uuid.uuid4().hex[:16]}"
self.client.graph.create(
# Graphiti creates the partition implicitly on first add_episode; this
# call just reserves the group_id.
self.client.create_graph(
graph_id=graph_id,
name=name,
description="MiroFish Social Simulation Graph"
description="MiroFish Social Simulation Graph",
)
return graph_id
def set_ontology(self, graph_id: str, ontology: Dict[str, Any]):
"""设置图谱本体(公开方法)"""
"""Set the graph ontology (public method)"""
import warnings
from typing import Optional
from pydantic import Field
from zep_cloud.external_clients.ontology import EntityModel, EntityText, EdgeModel
# 抑制 Pydantic v2 关于 Field(default=None) 的警告
# 这是 Zep SDK 要求的用法,警告来自动态类创建,可以安全忽略
# Base classes for the dynamic Pydantic ontology types. Try Zep's
# EntityModel/EdgeModel first (preserves a tiny bit of compatibility if
# zep-cloud is somehow still in the env), else fall back to Graphiti's
# EntityNode/EntityEdge which is what we actually use.
try:
from zep_cloud.external_clients.ontology import EntityModel, EntityText, EdgeModel
except ImportError:
try:
from graphiti_core.nodes import EntityNode as _GTEntity
from graphiti_core.edges import EntityEdge as _GTEdge
EntityModel = _GTEntity # type: ignore[assignment,misc]
EdgeModel = _GTEdge # type: ignore[assignment,misc]
EntityText = str # type: ignore[assignment,misc]
except ImportError:
from pydantic import BaseModel as EntityModel # type: ignore[assignment,misc]
from pydantic import BaseModel as EdgeModel # type: ignore[assignment,misc]
EntityText = str # type: ignore[assignment,misc]
# Suppress Pydantic v2 warning about Field(default=None)
# This is required by the Zep SDK; the warning comes from dynamic
# class creation and can safely be ignored.
warnings.filterwarnings('ignore', category=UserWarning, module='pydantic')
# Zep 保留名称,不能作为属性名
# Zep reserved names; cannot be used as attribute names
RESERVED_NAMES = {'uuid', 'name', 'group_id', 'name_embedding', 'summary', 'created_at'}
def safe_attr_name(attr_name: str) -> str:
"""将保留名称转换为安全名称"""
"""Convert a reserved name into a safe name"""
if attr_name.lower() in RESERVED_NAMES:
return f"entity_{attr_name}"
return attr_name
# 动态创建实体类型
# Dynamically create entity types
entity_types = {}
for entity_def in ontology.get("entity_types", []):
name = entity_def["name"]
description = entity_def.get("description", f"A {name} entity.")
# 创建属性字典和类型注解Pydantic v2 需要)
# Build the attribute dict and type annotations (required by Pydantic v2)
attrs = {"__doc__": description}
annotations = {}
for attr_def in entity_def.get("attributes", []):
attr_name = safe_attr_name(attr_def["name"]) # 使用安全名称
attr_name = safe_attr_name(attr_def["name"]) # Use the safe name
attr_desc = attr_def.get("description", attr_name)
# Zep API 需要 Field 的 description这是必需的
# Zep API requires a description on Field; this is required
attrs[attr_name] = Field(description=attr_desc, default=None)
annotations[attr_name] = Optional[EntityText] # 类型注解
annotations[attr_name] = Optional[EntityText] # type annotation
attrs["__annotations__"] = annotations
# 动态创建类
# Dynamically create the class
entity_class = type(name, (EntityModel,), attrs)
entity_class.__doc__ = description
entity_types[name] = entity_class
# 动态创建边类型
# Dynamically create edge types
edge_definitions = {}
for edge_def in ontology.get("edge_types", []):
name = edge_def["name"]
description = edge_def.get("description", f"A {name} relationship.")
# 创建属性字典和类型注解
# Build the attribute dict and type annotations
attrs = {"__doc__": description}
annotations = {}
for attr_def in edge_def.get("attributes", []):
attr_name = safe_attr_name(attr_def["name"]) # 使用安全名称
attr_name = safe_attr_name(attr_def["name"]) # Use the safe name
attr_desc = attr_def.get("description", attr_name)
# Zep API 需要 Field 的 description这是必需的
# Zep API requires a description on Field; this is required
attrs[attr_name] = Field(description=attr_desc, default=None)
annotations[attr_name] = Optional[str] # 边属性用str类型
annotations[attr_name] = Optional[str] # Edge attributes use str type
attrs["__annotations__"] = annotations
# 动态创建类
# Dynamically create the class
class_name = ''.join(word.capitalize() for word in name.split('_'))
edge_class = type(class_name, (EdgeModel,), attrs)
edge_class.__doc__ = description
# 构建source_targets
# Build source_targets
source_targets = []
for st in edge_def.get("source_targets", []):
source_targets.append(
@ -279,18 +314,19 @@ class GraphBuilderService:
target=st.get("target", "Entity")
)
)
if source_targets:
edge_definitions[name] = (edge_class, source_targets)
# 调用Zep API设置本体
# Graphiti accepts the original JSON ontology directly. The Pydantic
# class construction above was a Zep-specific quirk; we can skip it
# for Graphiti and just forward the input. Keep the code path the same
# shape (Pydantic EntityModel/EdgeModel classes still constructed and
# exposed to introspection code), but the actual set_ontology call now
# uses the adapter with the source-of-truth JSON.
if entity_types or edge_definitions:
self.client.graph.set_ontology(
graph_ids=[graph_id],
entities=entity_types if entity_types else None,
edges=edge_definitions if edge_definitions else None,
)
self.client.set_ontology(graph_id=graph_id, ontology=ontology)
def add_text_batches(
self,
graph_id: str,
@ -298,117 +334,80 @@ class GraphBuilderService:
batch_size: int = 3,
progress_callback: Optional[Callable] = None
) -> List[str]:
"""分批添加文本到图谱,返回所有 episode 的 uuid 列表"""
"""Add text to the graph in batches, returning a list of episode uuids"""
episode_uuids = []
total_chunks = len(chunks)
for i in range(0, total_chunks, batch_size):
batch_chunks = chunks[i:i + batch_size]
batch_num = i // batch_size + 1
total_batches = (total_chunks + batch_size - 1) // batch_size
if progress_callback:
progress = (i + len(batch_chunks)) / total_chunks
progress_callback(
t('progress.sendingBatch', current=batch_num, total=total_batches, chunks=len(batch_chunks)),
progress
)
# 构建episode数据
# Build episode data
episodes = [
EpisodeData(data=chunk, type="text")
for chunk in batch_chunks
]
# 发送到Zep
# Send to the graph (Graphiti via adapter — returns after sync extraction)
try:
batch_result = self.client.graph.add_batch(
batch_result = self.client.add_batch(
graph_id=graph_id,
episodes=episodes
episodes=episodes,
)
# 收集返回的 episode uuid
# Collect returned episode uuids
if batch_result and isinstance(batch_result, list):
for ep in batch_result:
ep_uuid = getattr(ep, 'uuid_', None) or getattr(ep, 'uuid', None)
if ep_uuid:
episode_uuids.append(ep_uuid)
# 避免请求过快
time.sleep(1)
except Exception as e:
if progress_callback:
progress_callback(t('progress.batchFailed', batch=batch_num, error=str(e)), 0)
raise
return episode_uuids
def _wait_for_episodes(
self,
episode_uuids: List[str],
progress_callback: Optional[Callable] = None,
timeout: int = 600
):
"""等待所有 episode 处理完成(通过查询每个 episode 的 processed 状态)"""
"""Wait for all episodes to finish processing.
Graphiti's add_episode is synchronous (extraction completes before the
call returns), so all episodes returned by add_batch are already done.
We still surface progress messages and respect the timeout for the
outer loop contract, but we never actually need to poll.
"""
if not episode_uuids:
if progress_callback:
progress_callback(t('progress.noEpisodesWait'), 1.0)
return
start_time = time.time()
pending_episodes = set(episode_uuids)
completed_count = 0
total_episodes = len(episode_uuids)
if progress_callback:
progress_callback(t('progress.waitingEpisodes', count=total_episodes), 0)
while pending_episodes:
if time.time() - start_time > timeout:
if progress_callback:
progress_callback(
t('progress.episodesTimeout', completed=completed_count, total=total_episodes),
completed_count / total_episodes
)
break
# 检查每个 episode 的处理状态
for ep_uuid in list(pending_episodes):
try:
episode = self.client.graph.episode.get(uuid_=ep_uuid)
is_processed = getattr(episode, 'processed', False)
if is_processed:
pending_episodes.remove(ep_uuid)
completed_count += 1
except Exception as e:
# 忽略单个查询错误,继续
pass
elapsed = int(time.time() - start_time)
if progress_callback:
progress_callback(
t('progress.zepProcessing', completed=completed_count, total=total_episodes, pending=len(pending_episodes), elapsed=elapsed),
completed_count / total_episodes if total_episodes > 0 else 0
)
if pending_episodes:
time.sleep(3) # 每3秒检查一次
if progress_callback:
progress_callback(t('progress.processingComplete', completed=completed_count, total=total_episodes), 1.0)
progress_callback(t('progress.waitingEpisodes', count=len(episode_uuids)), 0)
progress_callback(t('progress.processingComplete', completed=len(episode_uuids), total=len(episode_uuids)), 1.0)
def _get_graph_info(self, graph_id: str) -> GraphInfo:
"""获取图谱信息"""
# 获取节点(分页)
"""Get graph info"""
# Get nodes (paginated)
nodes = fetch_all_nodes(self.client, graph_id)
# 获取边(分页)
# Get edges (paginated)
edges = fetch_all_edges(self.client, graph_id)
# 统计实体类型
# Aggregate entity types
entity_types = set()
for node in nodes:
if node.labels:
@ -422,32 +421,33 @@ class GraphBuilderService:
edge_count=len(edges),
entity_types=list(entity_types)
)
def get_graph_data(self, graph_id: str) -> Dict[str, Any]:
"""
获取完整图谱数据包含详细信息
Get the complete graph data (including detailed info).
Args:
graph_id: 图谱ID
graph_id: Graph ID
Returns:
包含nodes和edges的字典包括时间信息属性等详细数据
A dict containing nodes and edges, including timing info,
attributes, and other detailed data.
"""
nodes = fetch_all_nodes(self.client, graph_id)
edges = fetch_all_edges(self.client, graph_id)
# 创建节点映射用于获取节点名称
# Build a node-name map
node_map = {}
for node in nodes:
node_map[node.uuid_] = node.name or ""
nodes_data = []
for node in nodes:
# 获取创建时间
# Get creation time
created_at = getattr(node, 'created_at', None)
if created_at:
created_at = str(created_at)
nodes_data.append({
"uuid": node.uuid_,
"name": node.name,
@ -456,25 +456,25 @@ class GraphBuilderService:
"attributes": node.attributes or {},
"created_at": created_at,
})
edges_data = []
for edge in edges:
# 获取时间信息
# Get time info
created_at = getattr(edge, 'created_at', None)
valid_at = getattr(edge, 'valid_at', None)
invalid_at = getattr(edge, 'invalid_at', None)
expired_at = getattr(edge, 'expired_at', None)
# 获取 episodes
# Get episodes
episodes = getattr(edge, 'episodes', None) or getattr(edge, 'episode_ids', None)
if episodes and not isinstance(episodes, list):
episodes = [str(episodes)]
elif episodes:
episodes = [str(e) for e in episodes]
# 获取 fact_type
# Get fact_type
fact_type = getattr(edge, 'fact_type', None) or edge.name or ""
edges_data.append({
"uuid": edge.uuid_,
"name": edge.name or "",
@ -491,7 +491,7 @@ class GraphBuilderService:
"expired_at": str(expired_at) if expired_at else None,
"episodes": episodes or [],
})
return {
"graph_id": graph_id,
"nodes": nodes_data,
@ -501,6 +501,6 @@ class GraphBuilderService:
}
def delete_graph(self, graph_id: str):
"""删除图谱"""
self.client.graph.delete(graph_id=graph_id)
"""Delete a graph"""
self.client.delete_graph(graph_id=graph_id)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
"""
本体生成服务
接口1分析文本内容生成适合社会模拟的实体和关系类型定义
Ontology generation service
Interface 1: analyze text content to generate entity and relationship type definitions suitable for social simulation
"""
import json
@ -14,169 +14,169 @@ logger = logging.getLogger(__name__)
def _to_pascal_case(name: str) -> str:
"""将任意格式的名称转换为 PascalCase 'works_for' -> 'WorksFor', 'person' -> 'Person'"""
# 按非字母数字字符分割
"""Convert any-format name to PascalCase (e.g. 'works_for' -> 'WorksFor', 'person' -> 'Person')"""
# Split by non-alphanumeric characters
parts = re.split(r'[^a-zA-Z0-9]+', name)
# 再按 camelCase 边界分割(如 'camelCase' -> ['camel', 'Case']
# Then split by camelCase boundaries (e.g. 'camelCase' -> ['camel', 'Case'])
words = []
for part in parts:
words.extend(re.sub(r'([a-z])([A-Z])', r'\1_\2', part).split('_'))
# 每个词首字母大写,过滤空串
# Capitalize each word, filter out empty strings
result = ''.join(word.capitalize() for word in words if word)
return result if result else 'Unknown'
# 本体生成的系统提示词
ONTOLOGY_SYSTEM_PROMPT = """你是一个专业的知识图谱本体设计专家。你的任务是分析给定的文本内容和模拟需求,设计适合**社交媒体舆论模拟**的实体类型和关系类型。
# System prompt for ontology generation
ONTOLOGY_SYSTEM_PROMPT = """You are a professional knowledge-graph ontology design expert. Your task is to analyze the given text content and simulation requirement, design entity types and relationship types suitable for **social media public-opinion simulation**.
**重要你必须输出有效的JSON格式数据不要输出任何其他内容**
**IMPORTANT: You MUST output valid JSON format data, and nothing else.**
## 核心任务背景
## Core Task Background
我们正在构建一个**社交媒体舆论模拟系统**在这个系统中
- 每个实体都是一个可以在社交媒体上发声互动传播信息的"账号""主体"
- 实体之间会相互影响转发评论回应
- 我们需要模拟舆论事件中各方的反应和信息传播路径
We are building a **social media public-opinion simulation system**. In this system:
- Each entity is an "account" or "subject" that can speak out, interact, and spread information on social media
- Entities influence each other, repost, comment, and respond
- We need to simulate the reactions of all parties and the information propagation paths in public-opinion events
因此**实体必须是现实中真实存在的可以在社媒上发声和互动的主体**
Therefore, **Entities must be real-world subjects that actually exist and can speak out and interact on social media**:
**可以是**
- 具体的个人公众人物当事人意见领袖专家学者普通人
- 公司企业包括其官方账号
- 组织机构大学协会NGO工会等
- 政府部门监管机构
- 媒体机构报纸电视台自媒体网站
- 社交媒体平台本身
- 特定群体代表如校友会粉丝团维权群体等
**Can be**:
- Specific individuals (public figures, parties involved, opinion leaders, experts and scholars, ordinary people)
- Companies, enterprises (including their official accounts)
- Organizations (universities, associations, NGOs, unions, etc.)
- Government departments, regulatory agencies
- Media organizations (newspapers, TV stations, self-media, websites)
- Social media platforms themselves
- Representatives of specific groups (e.g., alumni associations, fan groups, rights-advocacy groups, etc.)
**不可以是**
- 抽象概念"舆论""情绪""趋势"
- 主题/话题"学术诚信""教育改革"
- 观点/态度"支持方""反对方"
**Cannot be**:
- Abstract concepts (e.g., "public opinion", "emotion", "trend")
- Themes/topics (e.g., "academic integrity", "education reform")
- Views/attitudes (e.g., "supporters", "opponents")
## 输出格式
## Output Format
请输出JSON格式包含以下结构
Please output JSON format containing the following structure:
```json
{
"entity_types": [
{
"name": "实体类型名称英文PascalCase",
"description": "简短描述英文不超过100字符",
"name": "Entity type name (English, PascalCase)",
"description": "Short description (English, no more than 100 characters)",
"attributes": [
{
"name": "属性名英文snake_case",
"name": "Attribute name (English, snake_case)",
"type": "text",
"description": "属性描述"
"description": "Attribute description"
}
],
"examples": ["示例实体1", "示例实体2"]
"examples": ["Example entity 1", "Example entity 2"]
}
],
"edge_types": [
{
"name": "关系类型名称英文UPPER_SNAKE_CASE",
"description": "简短描述英文不超过100字符",
"name": "Relationship type name (English, UPPER_SNAKE_CASE)",
"description": "Short description (English, no more than 100 characters)",
"source_targets": [
{"source": "源实体类型", "target": "目标实体类型"}
{"source": "Source entity type", "target": "Target entity type"}
],
"attributes": []
}
],
"analysis_summary": "对文本内容的简要分析说明"
"analysis_summary": "Brief analysis and description of the text content"
}
```
## 设计指南(极其重要!)
## Design Guidelines (EXTREMELY IMPORTANT!)
### 1. 实体类型设计 - 必须严格遵守
### 1. Entity Type Design - MUST be strictly followed
**数量要求必须正好10个实体类型**
**Quantity requirement: must be exactly 10 entity types**
**层次结构要求必须同时包含具体类型和兜底类型**
**Hierarchy requirement (must include both specific types and fallback types)**:
你的10个实体类型必须包含以下层次
Your 10 entity types must include the following levels:
A. **兜底类型必须包含放在列表最后2个**
- `Person`: 任何自然人个体的兜底类型当一个人不属于其他更具体的人物类型时归入此类
- `Organization`: 任何组织机构的兜底类型当一个组织不属于其他更具体的组织类型时归入此类
A. **Fallback types (must be included, placed last 2 in the list)**:
- `Person`: Fallback type for any individual person. When a person does not fit any other more specific person type, they are classified here.
- `Organization`: Fallback type for any organization. When an organization does not fit any other more specific organization type, it is classified here.
B. **具体类型8根据文本内容设计**
- 针对文本中出现的主要角色设计更具体的类型
- 例如如果文本涉及学术事件可以有 `Student`, `Professor`, `University`
- 例如如果文本涉及商业事件可以有 `Company`, `CEO`, `Employee`
B. **Specific types (8, designed from the text content)**:
- Design more specific types for the main characters appearing in the text
- For example: if the text involves an academic event, you may have `Student`, `Professor`, `University`
- For example: if the text involves a business event, you may have `Company`, `CEO`, `Employee`
**为什么需要兜底类型**
- 文本中会出现各种人物"中小学教师""路人甲""某位网友"
- 如果没有专门的类型匹配他们应该被归入 `Person`
- 同理小型组织临时团体等应该归入 `Organization`
**Why fallback types are needed**:
- The text will contain various characters, such as "primary/secondary school teachers", "passerby A", "a certain netizen"
- If no specific type matches them, they should be classified as `Person`
- Similarly, small organizations, temporary groups, etc. should be classified as `Organization`
**具体类型的设计原则**
- 从文本中识别出高频出现或关键的角色类型
- 每个具体类型应该有明确的边界避免重叠
- description 必须清晰说明这个类型和兜底类型的区别
**Design principles for specific types**:
- Identify frequently occurring or key character types from the text
- Each specific type should have clear boundaries to avoid overlap
- The description must clearly explain the difference between this type and the fallback types
### 2. 关系类型设计
### 2. Relationship Type Design
- 数量6-10
- 关系应该反映社媒互动中的真实联系
- 确保关系的 source_targets 涵盖你定义的实体类型
- Quantity: 6-10
- Relationships should reflect real connections in social-media interactions
- Ensure the source_targets of relationships cover the entity types you defined
### 3. 属性设计
### 3. Attribute Design
- 每个实体类型1-3个关键属性
- **注意**属性名不能使用 `name``uuid``group_id``created_at``summary`这些是系统保留字
- 推荐使用`full_name`, `title`, `role`, `position`, `location`, `description`
- 1-3 key attributes per entity type
- **Note**: Attribute names must NOT use `name`, `uuid`, `group_id`, `created_at`, `summary` (these are system reserved words)
- Recommended: `full_name`, `title`, `role`, `position`, `location`, `description`, etc.
## 实体类型参考
## Entity Type Reference
**个人类具体**
- Student: 学生
- Professor: 教授/学者
- Journalist: 记者
- Celebrity: 明星/网红
- Executive: 高管
- Official: 政府官员
- Lawyer: 律师
- Doctor: 医生
**Person types (specific)**:
- Student: Student
- Professor: Professor/Scholar
- Journalist: Journalist
- Celebrity: Celebrity/Internet celebrity
- Executive: Executive
- Official: Government official
- Lawyer: Lawyer
- Doctor: Doctor
**个人类兜底**
- Person: 任何自然人不属于上述具体类型时使用
**Person types (fallback)**:
- Person: Any individual (used when not fitting the specific types above)
**组织类具体**
- University: 高校
- Company: 公司企业
- GovernmentAgency: 政府机构
- MediaOutlet: 媒体机构
- Hospital: 医院
- School: 中小学
- NGO: 非政府组织
**Organization types (specific)**:
- University: University/College
- Company: Company/Enterprise
- GovernmentAgency: Government agency
- MediaOutlet: Media organization
- Hospital: Hospital
- School: Primary/Secondary school
- NGO: Non-governmental organization
**组织类兜底**
- Organization: 任何组织机构不属于上述具体类型时使用
**Organization types (fallback)**:
- Organization: Any organization (used when not fitting the specific types above)
## 关系类型参考
## Relationship Type Reference
- WORKS_FOR: 工作于
- STUDIES_AT: 就读于
- AFFILIATED_WITH: 隶属于
- REPRESENTS: 代表
- REGULATES: 监管
- REPORTS_ON: 报道
- COMMENTS_ON: 评论
- RESPONDS_TO: 回应
- SUPPORTS: 支持
- OPPOSES: 反对
- COLLABORATES_WITH: 合作
- COMPETES_WITH: 竞争
- WORKS_FOR: Works for
- STUDIES_AT: Studies at
- AFFILIATED_WITH: Affiliated with
- REPRESENTS: Represents
- REGULATES: Regulates
- REPORTS_ON: Reports on
- COMMENTS_ON: Comments on
- RESPONDS_TO: Responds to
- SUPPORTS: Supports
- OPPOSES: Opposes
- COLLABORATES_WITH: Collaborates with
- COMPETES_WITH: Competes with
"""
class OntologyGenerator:
"""
本体生成器
分析文本内容生成实体和关系类型定义
Ontology generator
Analyze text content and generate entity and relationship type definitions
"""
def __init__(self, llm_client: Optional[LLMClient] = None):
@ -189,17 +189,17 @@ class OntologyGenerator:
additional_context: Optional[str] = None
) -> Dict[str, Any]:
"""
生成本体定义
Generate the ontology definition
Args:
document_texts: 文档文本列表
simulation_requirement: 模拟需求描述
additional_context: 额外上下文
document_texts: List of document texts
simulation_requirement: Simulation requirement description
additional_context: Additional context
Returns:
本体定义entity_types, edge_types等
Ontology definition (entity_types, edge_types, etc.)
"""
# 构建用户消息
# Build user message
user_message = self._build_user_message(
document_texts,
simulation_requirement,
@ -213,19 +213,48 @@ class OntologyGenerator:
{"role": "user", "content": user_message}
]
# 调用LLM
result = self.llm_client.chat_json(
messages=messages,
temperature=0.3,
max_tokens=4096
)
# 验证和后处理
# Call the LLM
try:
result = self.llm_client.chat_json(
messages=messages,
temperature=0.3,
max_tokens=16384,
)
except ValueError as json_err:
logger.warning(
f"[ontology] first attempt failed (likely M3 JSON truncation): {json_err}; retrying with compact prompt"
)
# Compact retry: limit the number of entity/edge types, drop attributes
compact_doc = "\n".join(document_texts)[:30000]
compact_messages = [
{
"role": "system",
"content": (
"Output ONLY valid JSON, no markdown, no commentary. "
"Schema: {entity_types:[{name:PascalCase,description:short}],"
"edge_types:[{name:UPPER_SNAKE_CASE,description:short}],"
"analysis_summary:one sentence}. "
"Limit to AT MOST 6 entity types and 6 edge types. "
"Do NOT include attributes arrays."
),
},
{
"role": "user",
"content": f"Text:\\n{compact_doc}\\n\\nRequirement: {simulation_requirement}\\n\\nReturn JSON.",
},
]
result = self.llm_client.chat_json(
messages=compact_messages,
temperature=0.2,
max_tokens=8192,
)
# Validate and post-process
result = self._validate_and_process(result)
return result
# 传给 LLM 的文本最大长度5万字
# Max text length to send to the LLM (50,000 chars)
MAX_TEXT_LENGTH_FOR_LLM = 50000
def _build_user_message(
@ -234,50 +263,50 @@ class OntologyGenerator:
simulation_requirement: str,
additional_context: Optional[str]
) -> str:
"""构建用户消息"""
"""Build user message"""
# 合并文本
# Merge text
combined_text = "\n\n---\n\n".join(document_texts)
original_length = len(combined_text)
# 如果文本超过5万字截断仅影响传给LLM的内容不影响图谱构建
# If text exceeds 50,000 chars, truncate (only affects what is sent to the LLM, not the graph construction)
if len(combined_text) > self.MAX_TEXT_LENGTH_FOR_LLM:
combined_text = combined_text[:self.MAX_TEXT_LENGTH_FOR_LLM]
combined_text += f"\n\n...(原文共{original_length}字,已截取前{self.MAX_TEXT_LENGTH_FOR_LLM}字用于本体分析)..."
combined_text += f"\n\n...(original text is {original_length} chars, truncated to the first {self.MAX_TEXT_LENGTH_FOR_LLM} chars for ontology analysis)..."
message = f"""## 模拟需求
message = f"""## Simulation requirements
{simulation_requirement}
## 文档内容
## Document content
{combined_text}
"""
if additional_context:
message += f"""
## 额外说明
## Additional notes
{additional_context}
"""
message += """
请根据以上内容设计适合社会舆论模拟的实体类型和关系类型
Please design entity types and edge types suitable for social opinion simulation based on the content above.
**必须遵守的规则**
1. 必须正好输出10个实体类型
2. 最后2个必须是兜底类型Person个人兜底 Organization组织兜底
3. 前8个是根据文本内容设计的具体类型
4. 所有实体类型必须是现实中可以发声的主体不能是抽象概念
5. 属性名不能使用 nameuuidgroup_id 等保留字 full_nameorg_name 等替代
**Rules that must be followed**:
1. Output exactly 10 entity types
2. The last 2 must be fallback types: Person (individual fallback) and Organization (organization fallback)
3. The first 8 should be specific types designed from the text content
4. All entity types must be real-world entities capable of speaking out, not abstract concepts
5. Attribute names must not use reserved words like name, uuid, group_id; use full_name, org_name, etc. instead
"""
return message
def _validate_and_process(self, result: Dict[str, Any]) -> Dict[str, Any]:
"""验证和后处理结果"""
"""Validate and post-process the result"""
# 确保必要字段存在
# Ensure required fields exist
if "entity_types" not in result:
result["entity_types"] = []
if "edge_types" not in result:
@ -285,11 +314,11 @@ class OntologyGenerator:
if "analysis_summary" not in result:
result["analysis_summary"] = ""
# 验证实体类型
# 记录原始名称到 PascalCase 的映射,用于后续修正 edge 的 source_targets 引用
# Validate entity types
# Record the raw-name -> PascalCase mapping, used later to fix edge source_targets references
entity_name_map = {}
for entity in result["entity_types"]:
# 强制将 entity name 转为 PascalCaseZep API 要求)
# Force-convert entity name to PascalCase (Zep API requirement)
if "name" in entity:
original_name = entity["name"]
entity["name"] = _to_pascal_case(original_name)
@ -300,19 +329,19 @@ class OntologyGenerator:
entity["attributes"] = []
if "examples" not in entity:
entity["examples"] = []
# 确保description不超过100字符
# Ensure description does not exceed 100 characters
if len(entity.get("description", "")) > 100:
entity["description"] = entity["description"][:97] + "..."
# 验证关系类型
# Validate edge types
for edge in result["edge_types"]:
# 强制将 edge name 转为 SCREAMING_SNAKE_CASEZep API 要求)
# Force-convert edge name to SCREAMING_SNAKE_CASE (Zep API requirement)
if "name" in edge:
original_name = edge["name"]
edge["name"] = original_name.upper()
if edge["name"] != original_name:
logger.warning(f"Edge type name '{original_name}' auto-converted to '{edge['name']}'")
# 修正 source_targets 中的实体名称引用,与转换后的 PascalCase 保持一致
# Fix entity name references in source_targets to match the converted PascalCase
for st in edge.get("source_targets", []):
if st.get("source") in entity_name_map:
st["source"] = entity_name_map[st["source"]]
@ -325,11 +354,11 @@ class OntologyGenerator:
if len(edge.get("description", "")) > 100:
edge["description"] = edge["description"][:97] + "..."
# Zep API 限制:最多 10 个自定义实体类型,最多 10 个自定义边类型
# Zep API limit: at most 10 custom entity types, at most 10 custom edge types
MAX_ENTITY_TYPES = 10
MAX_EDGE_TYPES = 10
# 去重:按 name 去重,保留首次出现的
# Deduplicate by name, keep the first occurrence
seen_names = set()
deduped = []
for entity in result["entity_types"]:
@ -341,7 +370,7 @@ class OntologyGenerator:
logger.warning(f"Duplicate entity type '{name}' removed during validation")
result["entity_types"] = deduped
# 兜底类型定义
# Fallback type definitions
person_fallback = {
"name": "Person",
"description": "Any individual person not fitting other specific person types.",
@ -362,12 +391,12 @@ class OntologyGenerator:
"examples": ["small business", "community group"]
}
# 检查是否已有兜底类型
# Check whether fallback types already exist
entity_names = {e["name"] for e in result["entity_types"]}
has_person = "Person" in entity_names
has_organization = "Organization" in entity_names
# 需要添加的兜底类型
# Fallback types that need to be added
fallbacks_to_add = []
if not has_person:
fallbacks_to_add.append(person_fallback)
@ -378,17 +407,17 @@ class OntologyGenerator:
current_count = len(result["entity_types"])
needed_slots = len(fallbacks_to_add)
# 如果添加后会超过 10 个,需要移除一些现有类型
# If adding would exceed 10, we need to drop some existing types
if current_count + needed_slots > MAX_ENTITY_TYPES:
# 计算需要移除多少个
# Compute how many to remove
to_remove = current_count + needed_slots - MAX_ENTITY_TYPES
# 从末尾移除(保留前面更重要的具体类型)
# Remove from the end (keep the more important specific types at the front)
result["entity_types"] = result["entity_types"][:-to_remove]
# 添加兜底类型
# Add fallback types
result["entity_types"].extend(fallbacks_to_add)
# 最终确保不超过限制(防御性编程)
# Final guard to ensure the limit is not exceeded (defensive coding)
if len(result["entity_types"]) > MAX_ENTITY_TYPES:
result["entity_types"] = result["entity_types"][:MAX_ENTITY_TYPES]
@ -399,29 +428,29 @@ class OntologyGenerator:
def generate_python_code(self, ontology: Dict[str, Any]) -> str:
"""
将本体定义转换为Python代码类似ontology.py
Convert the ontology definition to Python code (similar to ontology.py)
Args:
ontology: 本体定义
ontology: Ontology definition
Returns:
Python代码字符串
Python code string
"""
code_lines = [
'"""',
'自定义实体类型定义',
'由MiroFish自动生成用于社会舆论模拟',
'Custom entity type definitions',
'Auto-generated by MiroFish, for social opinion simulation',
'"""',
'',
'from pydantic import Field',
'from zep_cloud.external_clients.ontology import EntityModel, EntityText, EdgeModel',
'',
'',
'# ============== 实体类型定义 ==============',
'# ============== Entity type definitions ==============',
'',
]
# 生成实体类型
# Generate entity types
for entity in ontology.get("entity_types", []):
name = entity["name"]
desc = entity.get("description", f"A {name} entity.")
@ -444,13 +473,13 @@ class OntologyGenerator:
code_lines.append('')
code_lines.append('')
code_lines.append('# ============== 关系类型定义 ==============')
code_lines.append('# ============== Edge type definitions ==============')
code_lines.append('')
# 生成关系类型
# Generate edge types
for edge in ontology.get("edge_types", []):
name = edge["name"]
# 转换为PascalCase类名
# Convert to PascalCase class name
class_name = ''.join(word.capitalize() for word in name.split('_'))
desc = edge.get("description", f"A {name} relationship.")
@ -472,8 +501,8 @@ class OntologyGenerator:
code_lines.append('')
code_lines.append('')
# 生成类型字典
code_lines.append('# ============== 类型配置 ==============')
# Generate type dictionary
code_lines.append('# ============== Type configuration ==============')
code_lines.append('')
code_lines.append('ENTITY_TYPES = {')
for entity in ontology.get("entity_types", []):
@ -489,7 +518,7 @@ class OntologyGenerator:
code_lines.append('}')
code_lines.append('')
# 生成边的source_targets映射
# Generate edge source_targets mapping
code_lines.append('EDGE_SOURCE_TARGETS = {')
for edge in ontology.get("edge_types", []):
name = edge["name"]

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,11 +1,13 @@
"""
模拟IPC通信模块
用于Flask后端和模拟脚本之间的进程间通信
Simulation IPC communication module
Used for inter-process communication between the Flask backend
and the simulation script.
通过文件系统实现简单的命令/响应模式
1. Flask写入命令到 commands/ 目录
2. 模拟脚本轮询命令目录执行命令并写入响应到 responses/ 目录
3. Flask轮询响应目录获取结果
Implements a simple command/response pattern via the filesystem:
1. Flask writes commands into the commands/ directory
2. The simulation script polls the commands directory, executes the
commands, and writes responses into the responses/ directory
3. Flask polls the responses directory to fetch results
"""
import os
@ -23,14 +25,14 @@ logger = get_logger('mirofish.simulation_ipc')
class CommandType(str, Enum):
"""命令类型"""
INTERVIEW = "interview" # 单个Agent采访
BATCH_INTERVIEW = "batch_interview" # 批量采访
CLOSE_ENV = "close_env" # 关闭环境
"""Command type"""
INTERVIEW = "interview" # Single-agent interview
BATCH_INTERVIEW = "batch_interview" # Batch interview
CLOSE_ENV = "close_env" # Close environment
class CommandStatus(str, Enum):
"""命令状态"""
"""Command status"""
PENDING = "pending"
PROCESSING = "processing"
COMPLETED = "completed"
@ -39,12 +41,12 @@ class CommandStatus(str, Enum):
@dataclass
class IPCCommand:
"""IPC命令"""
"""IPC command"""
command_id: str
command_type: CommandType
args: Dict[str, Any]
timestamp: str = field(default_factory=lambda: datetime.now().isoformat())
def to_dict(self) -> Dict[str, Any]:
return {
"command_id": self.command_id,
@ -52,7 +54,7 @@ class IPCCommand:
"args": self.args,
"timestamp": self.timestamp
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'IPCCommand':
return cls(
@ -65,13 +67,13 @@ class IPCCommand:
@dataclass
class IPCResponse:
"""IPC响应"""
"""IPC response"""
command_id: str
status: CommandStatus
result: Optional[Dict[str, Any]] = None
error: Optional[str] = None
timestamp: str = field(default_factory=lambda: datetime.now().isoformat())
def to_dict(self) -> Dict[str, Any]:
return {
"command_id": self.command_id,
@ -80,7 +82,7 @@ class IPCResponse:
"error": self.error,
"timestamp": self.timestamp
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'IPCResponse':
return cls(
@ -94,26 +96,26 @@ class IPCResponse:
class SimulationIPCClient:
"""
模拟IPC客户端Flask端使用
用于向模拟进程发送命令并等待响应
Simulation IPC client (used on the Flask side)
Sends commands to the simulation process and waits for responses.
"""
def __init__(self, simulation_dir: str):
"""
初始化IPC客户端
Initialize the IPC client.
Args:
simulation_dir: 模拟数据目录
simulation_dir: Simulation data directory
"""
self.simulation_dir = simulation_dir
self.commands_dir = os.path.join(simulation_dir, "ipc_commands")
self.responses_dir = os.path.join(simulation_dir, "ipc_responses")
# 确保目录存在
# Ensure directories exist
os.makedirs(self.commands_dir, exist_ok=True)
os.makedirs(self.responses_dir, exist_ok=True)
def send_command(
self,
command_type: CommandType,
@ -122,19 +124,19 @@ class SimulationIPCClient:
poll_interval: float = 0.5
) -> IPCResponse:
"""
发送命令并等待响应
Send a command and wait for a response.
Args:
command_type: 命令类型
args: 命令参数
timeout: 超时时间
poll_interval: 轮询间隔
command_type: Command type
args: Command arguments
timeout: Timeout in seconds
poll_interval: Poll interval in seconds
Returns:
IPCResponse
Raises:
TimeoutError: 等待响应超时
TimeoutError: Timed out waiting for a response
"""
command_id = str(uuid.uuid4())
command = IPCCommand(
@ -142,50 +144,50 @@ class SimulationIPCClient:
command_type=command_type,
args=args
)
# 写入命令文件
# Write the command file
command_file = os.path.join(self.commands_dir, f"{command_id}.json")
with open(command_file, 'w', encoding='utf-8') as f:
json.dump(command.to_dict(), f, ensure_ascii=False, indent=2)
logger.info(f"发送IPC命令: {command_type.value}, command_id={command_id}")
# 等待响应
logger.info(f"Sent IPC command: {command_type.value}, command_id={command_id}")
# Wait for the response
response_file = os.path.join(self.responses_dir, f"{command_id}.json")
start_time = time.time()
while time.time() - start_time < timeout:
if os.path.exists(response_file):
try:
with open(response_file, 'r', encoding='utf-8') as f:
response_data = json.load(f)
response = IPCResponse.from_dict(response_data)
# 清理命令和响应文件
# Clean up command and response files
try:
os.remove(command_file)
os.remove(response_file)
except OSError:
pass
logger.info(f"收到IPC响应: command_id={command_id}, status={response.status.value}")
logger.info(f"Received IPC response: command_id={command_id}, status={response.status.value}")
return response
except (json.JSONDecodeError, KeyError) as e:
logger.warning(f"解析响应失败: {e}")
logger.warning(f"Failed to parse response: {e}")
time.sleep(poll_interval)
# 超时
logger.error(f"等待IPC响应超时: command_id={command_id}")
# 清理命令文件
# Timeout
logger.error(f"Timeout waiting for IPC response: command_id={command_id}")
# Clean up the command file
try:
os.remove(command_file)
except OSError:
pass
raise TimeoutError(f"等待命令响应超时 ({timeout})")
raise TimeoutError(f"Timed out waiting for command response ({timeout}s)")
def send_interview(
self,
agent_id: int,
@ -194,19 +196,21 @@ class SimulationIPCClient:
timeout: float = 60.0
) -> IPCResponse:
"""
发送单个Agent采访命令
Send a single-Agent interview command.
Args:
agent_id: Agent ID
prompt: 采访问题
platform: 指定平台可选
- "twitter": 只采访Twitter平台
- "reddit": 只采访Reddit平台
- None: 双平台模拟时同时采访两个平台单平台模拟时采访该平台
timeout: 超时时间
prompt: Interview question
platform: Specific platform (optional)
- "twitter": only interview the Twitter platform
- "reddit": only interview the Reddit platform
- None: in a dual-platform simulation interview both
platforms; in a single-platform simulation interview
that platform
timeout: Timeout
Returns:
IPCResponseresult字段包含采访结果
IPCResponse; the result field contains the interview result
"""
args = {
"agent_id": agent_id,
@ -214,13 +218,13 @@ class SimulationIPCClient:
}
if platform:
args["platform"] = platform
return self.send_command(
command_type=CommandType.INTERVIEW,
args=args,
timeout=timeout
)
def send_batch_interview(
self,
interviews: List[Dict[str, Any]],
@ -228,36 +232,39 @@ class SimulationIPCClient:
timeout: float = 120.0
) -> IPCResponse:
"""
发送批量采访命令
Send a batch interview command.
Args:
interviews: 采访列表每个元素包含 {"agent_id": int, "prompt": str, "platform": str(可选)}
platform: 默认平台可选会被每个采访项的platform覆盖
- "twitter": 默认只采访Twitter平台
- "reddit": 默认只采访Reddit平台
- None: 双平台模拟时每个Agent同时采访两个平台
timeout: 超时时间
interviews: List of interviews; each item is
{"agent_id": int, "prompt": str, "platform": str (optional)}
platform: Default platform (optional; overridden by each
interview item's platform)
- "twitter": default to interviewing only Twitter
- "reddit": default to interviewing only Reddit
- None: in a dual-platform simulation interview each
Agent on both platforms
timeout: Timeout
Returns:
IPCResponseresult字段包含所有采访结果
IPCResponse; the result field contains all interview results
"""
args = {"interviews": interviews}
if platform:
args["platform"] = platform
return self.send_command(
command_type=CommandType.BATCH_INTERVIEW,
args=args,
timeout=timeout
)
def send_close_env(self, timeout: float = 30.0) -> IPCResponse:
"""
发送关闭环境命令
Send a close-environment command.
Args:
timeout: 超时时间
timeout: Timeout
Returns:
IPCResponse
"""
@ -266,17 +273,17 @@ class SimulationIPCClient:
args={},
timeout=timeout
)
def check_env_alive(self) -> bool:
"""
检查模拟环境是否存活
通过检查 env_status.json 文件来判断
Check whether the simulation environment is alive.
Decides by checking the env_status.json file.
"""
status_file = os.path.join(self.simulation_dir, "env_status.json")
if not os.path.exists(status_file):
return False
try:
with open(status_file, 'r', encoding='utf-8') as f:
status = json.load(f)
@ -287,106 +294,107 @@ class SimulationIPCClient:
class SimulationIPCServer:
"""
模拟IPC服务器模拟脚本端使用
轮询命令目录执行命令并返回响应
Simulation IPC server (used on the simulation-script side)
Polls the commands directory, executes commands, and returns
responses.
"""
def __init__(self, simulation_dir: str):
"""
初始化IPC服务器
Initialize the IPC server.
Args:
simulation_dir: 模拟数据目录
simulation_dir: Simulation data directory
"""
self.simulation_dir = simulation_dir
self.commands_dir = os.path.join(simulation_dir, "ipc_commands")
self.responses_dir = os.path.join(simulation_dir, "ipc_responses")
# 确保目录存在
# Ensure directories exist
os.makedirs(self.commands_dir, exist_ok=True)
os.makedirs(self.responses_dir, exist_ok=True)
# 环境状态
# Environment status
self._running = False
def start(self):
"""标记服务器为运行状态"""
"""Mark the server as running"""
self._running = True
self._update_env_status("alive")
def stop(self):
"""标记服务器为停止状态"""
"""Mark the server as stopped"""
self._running = False
self._update_env_status("stopped")
def _update_env_status(self, status: str):
"""更新环境状态文件"""
"""Update the environment-status file"""
status_file = os.path.join(self.simulation_dir, "env_status.json")
with open(status_file, 'w', encoding='utf-8') as f:
json.dump({
"status": status,
"timestamp": datetime.now().isoformat()
}, f, ensure_ascii=False, indent=2)
def poll_commands(self) -> Optional[IPCCommand]:
"""
轮询命令目录返回第一个待处理的命令
Poll the commands directory; return the first pending command.
Returns:
IPCCommand None
IPCCommand or None
"""
if not os.path.exists(self.commands_dir):
return None
# 按时间排序获取命令文件
# Sort command files by modification time
command_files = []
for filename in os.listdir(self.commands_dir):
if filename.endswith('.json'):
filepath = os.path.join(self.commands_dir, filename)
command_files.append((filepath, os.path.getmtime(filepath)))
command_files.sort(key=lambda x: x[1])
for filepath, _ in command_files:
try:
with open(filepath, 'r', encoding='utf-8') as f:
data = json.load(f)
return IPCCommand.from_dict(data)
except (json.JSONDecodeError, KeyError, OSError) as e:
logger.warning(f"读取命令文件失败: {filepath}, {e}")
logger.warning(f"Failed to read command file: {filepath}, {e}")
continue
return None
def send_response(self, response: IPCResponse):
"""
发送响应
Send a response.
Args:
response: IPC响应
response: IPC response
"""
response_file = os.path.join(self.responses_dir, f"{response.command_id}.json")
with open(response_file, 'w', encoding='utf-8') as f:
json.dump(response.to_dict(), f, ensure_ascii=False, indent=2)
# 删除命令文件
# Delete the command file
command_file = os.path.join(self.commands_dir, f"{response.command_id}.json")
try:
os.remove(command_file)
except OSError:
pass
def send_success(self, command_id: str, result: Dict[str, Any]):
"""发送成功响应"""
"""Send a success response"""
self.send_response(IPCResponse(
command_id=command_id,
status=CommandStatus.COMPLETED,
result=result
))
def send_error(self, command_id: str, error: str):
"""发送错误响应"""
"""Send an error response"""
self.send_response(IPCResponse(
command_id=command_id,
status=CommandStatus.FAILED,

View File

@ -1,7 +1,7 @@
"""
OASIS模拟管理器
管理Twitter和Reddit双平台并行模拟
使用预设脚本 + LLM智能生成配置参数
OASIS Simulation Manager
Manages parallel Twitter and Reddit dual-platform simulations
Uses preset scripts + LLM-intelligent configuration parameter generation
"""
import os
@ -23,60 +23,60 @@ logger = get_logger('mirofish.simulation')
class SimulationStatus(str, Enum):
"""模拟状态"""
"""Simulation status"""
CREATED = "created"
PREPARING = "preparing"
READY = "ready"
RUNNING = "running"
PAUSED = "paused"
STOPPED = "stopped" # 模拟被手动停止
COMPLETED = "completed" # 模拟自然完成
STOPPED = "stopped" # Simulation was manually stopped
COMPLETED = "completed" # Simulation completed naturally
FAILED = "failed"
class PlatformType(str, Enum):
"""平台类型"""
"""Platform type"""
TWITTER = "twitter"
REDDIT = "reddit"
@dataclass
class SimulationState:
"""模拟状态"""
"""Simulation state"""
simulation_id: str
project_id: str
graph_id: str
# 平台启用状态
# Platform enable status
enable_twitter: bool = True
enable_reddit: bool = True
# 状态
# Status
status: SimulationStatus = SimulationStatus.CREATED
# 准备阶段数据
# Preparation stage data
entities_count: int = 0
profiles_count: int = 0
entity_types: List[str] = field(default_factory=list)
# 配置生成信息
# Config generation info
config_generated: bool = False
config_reasoning: str = ""
# 运行时数据
# Runtime data
current_round: int = 0
twitter_status: str = "not_started"
reddit_status: str = "not_started"
# 时间戳
# Timestamps
created_at: str = field(default_factory=lambda: datetime.now().isoformat())
updated_at: str = field(default_factory=lambda: datetime.now().isoformat())
# 错误信息
# Error info
error: Optional[str] = None
def to_dict(self) -> Dict[str, Any]:
"""完整状态字典(内部使用)"""
"""Full state dictionary (for internal use)"""
return {
"simulation_id": self.simulation_id,
"project_id": self.project_id,
@ -98,7 +98,7 @@ class SimulationState:
}
def to_simple_dict(self) -> Dict[str, Any]:
"""简化状态字典API返回使用"""
"""Simplified state dictionary (for API response)"""
return {
"simulation_id": self.simulation_id,
"project_id": self.project_id,
@ -114,36 +114,36 @@ class SimulationState:
class SimulationManager:
"""
模拟管理器
核心功能
1. 从Zep图谱读取实体并过滤
2. 生成OASIS Agent Profile
3. 使用LLM智能生成模拟配置参数
4. 准备预设脚本所需的所有文件
Simulation Manager
Core features:
1. Read entities from Zep graph and filter
2. Generate OASIS Agent Profile
3. Use LLM to intelligently generate simulation configuration parameters
4. Prepare all files required by the preset scripts
"""
# 模拟数据存储目录
# Simulation data storage directory
SIMULATION_DATA_DIR = os.path.join(
os.path.dirname(__file__),
os.path.dirname(__file__),
'../../uploads/simulations'
)
def __init__(self):
# 确保目录存在
# Ensure the directory exists
os.makedirs(self.SIMULATION_DATA_DIR, exist_ok=True)
# 内存中的模拟状态缓存
# In-memory simulation state cache
self._simulations: Dict[str, SimulationState] = {}
def _get_simulation_dir(self, simulation_id: str) -> str:
"""获取模拟数据目录"""
"""Get the simulation data directory"""
sim_dir = os.path.join(self.SIMULATION_DATA_DIR, simulation_id)
os.makedirs(sim_dir, exist_ok=True)
return sim_dir
def _save_simulation_state(self, state: SimulationState):
"""保存模拟状态到文件"""
"""Save the simulation state to file"""
sim_dir = self._get_simulation_dir(state.simulation_id)
state_file = os.path.join(sim_dir, "state.json")
@ -155,7 +155,7 @@ class SimulationManager:
self._simulations[state.simulation_id] = state
def _load_simulation_state(self, simulation_id: str) -> Optional[SimulationState]:
"""从文件加载模拟状态"""
"""Load the simulation state from file"""
if simulation_id in self._simulations:
return self._simulations[simulation_id]
@ -199,20 +199,20 @@ class SimulationManager:
enable_reddit: bool = True,
) -> SimulationState:
"""
创建新的模拟
Create a new simulation
Args:
project_id: 项目ID
graph_id: Zep图谱ID
enable_twitter: 是否启用Twitter模拟
enable_reddit: 是否启用Reddit模拟
project_id: Project ID
graph_id: Zep graph ID
enable_twitter: Whether to enable Twitter simulation
enable_reddit: Whether to enable Reddit simulation
Returns:
SimulationState
"""
import uuid
simulation_id = f"sim_{uuid.uuid4().hex[:12]}"
state = SimulationState(
simulation_id=simulation_id,
project_id=project_id,
@ -221,9 +221,9 @@ class SimulationManager:
enable_reddit=enable_reddit,
status=SimulationStatus.CREATED,
)
self._save_simulation_state(state)
logger.info(f"创建模拟: {simulation_id}, project={project_id}, graph={graph_id}")
logger.info(f"Created simulation: {simulation_id}, project={project_id}, graph={graph_id}")
return state
@ -238,30 +238,30 @@ class SimulationManager:
parallel_profile_count: int = 3
) -> SimulationState:
"""
准备模拟环境全程自动化
步骤
1. 从Zep图谱读取并过滤实体
2. 为每个实体生成OASIS Agent Profile可选LLM增强支持并行
3. 使用LLM智能生成模拟配置参数时间活跃度发言频率等
4. 保存配置文件和Profile文件
5. 复制预设脚本到模拟目录
Prepare the simulation environment (fully automated)
Steps:
1. Read and filter entities from the Zep graph
2. Generate OASIS Agent Profile for each entity (optional LLM enhancement, supports parallelism)
3. Use LLM to intelligently generate simulation configuration parameters (time, activity, posting frequency, etc.)
4. Save config and profile files
5. Copy preset scripts to the simulation directory
Args:
simulation_id: 模拟ID
simulation_requirement: 模拟需求描述用于LLM生成配置
document_text: 原始文档内容用于LLM理解背景
defined_entity_types: 预定义的实体类型可选
use_llm_for_profiles: 是否使用LLM生成详细人设
progress_callback: 进度回调函数 (stage, progress, message)
parallel_profile_count: 并行生成人设的数量默认3
simulation_id: Simulation ID
simulation_requirement: Simulation requirement description (used for LLM config generation)
document_text: Original document content (used for LLM to understand context)
defined_entity_types: Predefined entity types (optional)
use_llm_for_profiles: Whether to use LLM to generate detailed personas
progress_callback: Progress callback function (stage, progress, message)
parallel_profile_count: Number of personas to generate in parallel, default 3
Returns:
SimulationState
"""
state = self._load_simulation_state(simulation_id)
if not state:
raise ValueError(f"模拟不存在: {simulation_id}")
raise ValueError(f"Simulation not found: {simulation_id}")
try:
state.status = SimulationStatus.PREPARING
@ -269,24 +269,24 @@ class SimulationManager:
sim_dir = self._get_simulation_dir(simulation_id)
# ========== 阶段1: 读取并过滤实体 ==========
# ========== Stage 1: Read and filter entities ==========
if progress_callback:
progress_callback("reading", 0, t('progress.connectingZepGraph'))
reader = ZepEntityReader()
if progress_callback:
progress_callback("reading", 30, t('progress.readingNodeData'))
filtered = reader.filter_defined_entities(
graph_id=state.graph_id,
defined_entity_types=defined_entity_types,
enrich_with_edges=True
)
state.entities_count = filtered.filtered_count
state.entity_types = list(filtered.entity_types)
if progress_callback:
progress_callback(
"reading", 100,
@ -294,16 +294,16 @@ class SimulationManager:
current=filtered.filtered_count,
total=filtered.filtered_count
)
if filtered.filtered_count == 0:
state.status = SimulationStatus.FAILED
state.error = "没有找到符合条件的实体,请检查图谱是否正确构建"
state.error = "No entities matching the criteria were found. Please check whether the graph was built correctly."
self._save_simulation_state(state)
return state
# ========== 阶段2: 生成Agent Profile ==========
# ========== Stage 2: Generate Agent Profile ==========
total_entities = len(filtered.entities)
if progress_callback:
progress_callback(
"generating_profiles", 0,
@ -311,22 +311,22 @@ class SimulationManager:
current=0,
total=total_entities
)
# 传入graph_id以启用Zep检索功能获取更丰富的上下文
# Pass graph_id to enable Zep retrieval for richer context
generator = OasisProfileGenerator(graph_id=state.graph_id)
def profile_progress(current, total, msg):
if progress_callback:
progress_callback(
"generating_profiles",
int(current / total * 100),
"generating_profiles",
int(current / total * 100),
msg,
current=current,
total=total,
item_name=msg
)
# 设置实时保存的文件路径(优先使用 Reddit JSON 格式)
# Configure real-time save path (prefer Reddit JSON format)
realtime_output_path = None
realtime_platform = "reddit"
if state.enable_reddit:
@ -335,21 +335,21 @@ class SimulationManager:
elif state.enable_twitter:
realtime_output_path = os.path.join(sim_dir, "twitter_profiles.csv")
realtime_platform = "twitter"
profiles = generator.generate_profiles_from_entities(
entities=filtered.entities,
use_llm=use_llm_for_profiles,
progress_callback=profile_progress,
graph_id=state.graph_id, # 传入graph_id用于Zep检索
parallel_count=parallel_profile_count, # 并行生成数量
realtime_output_path=realtime_output_path, # 实时保存路径
output_platform=realtime_platform # 输出格式
graph_id=state.graph_id, # Pass graph_id for Zep retrieval
parallel_count=parallel_profile_count, # Parallel generation count
realtime_output_path=realtime_output_path, # Real-time save path
output_platform=realtime_platform # Output format
)
state.profiles_count = len(profiles)
# 保存Profile文件注意Twitter使用CSV格式Reddit使用JSON格式
# Reddit 已经在生成过程中实时保存了,这里再保存一次确保完整性
# Save profile files (note: Twitter uses CSV, Reddit uses JSON)
# Reddit has already been saved in real time during generation; saving once more here ensures completeness
if progress_callback:
progress_callback(
"generating_profiles", 95,
@ -357,16 +357,16 @@ class SimulationManager:
current=total_entities,
total=total_entities
)
if state.enable_reddit:
generator.save_profiles(
profiles=profiles,
file_path=os.path.join(sim_dir, "reddit_profiles.json"),
platform="reddit"
)
if state.enable_twitter:
# Twitter使用CSV格式这是OASIS的要求
# Twitter uses CSV format! This is required by OASIS
generator.save_profiles(
profiles=profiles,
file_path=os.path.join(sim_dir, "twitter_profiles.csv"),
@ -381,7 +381,7 @@ class SimulationManager:
total=len(profiles)
)
# ========== 阶段3: LLM智能生成模拟配置 ==========
# ========== Stage 3: LLM-intelligent generation of simulation config ==========
if progress_callback:
progress_callback(
"generating_config", 0,
@ -389,9 +389,9 @@ class SimulationManager:
current=0,
total=3
)
config_generator = SimulationConfigGenerator()
if progress_callback:
progress_callback(
"generating_config", 30,
@ -399,7 +399,7 @@ class SimulationManager:
current=1,
total=3
)
sim_params = config_generator.generate_config(
simulation_id=simulation_id,
project_id=state.project_id,
@ -410,7 +410,7 @@ class SimulationManager:
enable_twitter=state.enable_twitter,
enable_reddit=state.enable_reddit
)
if progress_callback:
progress_callback(
"generating_config", 70,
@ -418,15 +418,15 @@ class SimulationManager:
current=2,
total=3
)
# 保存配置文件
# Save config file
config_path = os.path.join(sim_dir, "simulation_config.json")
with open(config_path, 'w', encoding='utf-8') as f:
f.write(sim_params.to_json())
state.config_generated = True
state.config_reasoning = sim_params.generation_reasoning
if progress_callback:
progress_callback(
"generating_config", 100,
@ -434,82 +434,83 @@ class SimulationManager:
current=3,
total=3
)
# 注意:运行脚本保留在 backend/scripts/ 目录,不再复制到模拟目录
# 启动模拟时simulation_runner 会从 scripts/ 目录运行脚本
# 更新状态
# Note: run scripts are kept in backend/scripts/ and are no longer copied into the simulation dir
# When starting a simulation, simulation_runner runs scripts from the scripts/ directory
# Update state
state.status = SimulationStatus.READY
self._save_simulation_state(state)
logger.info(f"模拟准备完成: {simulation_id}, "
logger.info(f"Simulation preparation complete: {simulation_id}, "
f"entities={state.entities_count}, profiles={state.profiles_count}")
return state
except Exception as e:
logger.error(f"模拟准备失败: {simulation_id}, error={str(e)}")
logger.error(f"Simulation preparation failed: {simulation_id}, error={str(e)}")
import traceback
logger.error(traceback.format_exc())
state.status = SimulationStatus.FAILED
state.error = str(e)
self._save_simulation_state(state)
raise
def get_simulation(self, simulation_id: str) -> Optional[SimulationState]:
"""获取模拟状态"""
"""Get the simulation state"""
return self._load_simulation_state(simulation_id)
def list_simulations(self, project_id: Optional[str] = None) -> List[SimulationState]:
"""列出所有模拟"""
"""List all simulations"""
simulations = []
if os.path.exists(self.SIMULATION_DATA_DIR):
for sim_id in os.listdir(self.SIMULATION_DATA_DIR):
# 跳过隐藏文件(如 .DS_Store和非目录文件
# Skip hidden files (e.g. .DS_Store) and non-directory entries
sim_path = os.path.join(self.SIMULATION_DATA_DIR, sim_id)
if sim_id.startswith('.') or not os.path.isdir(sim_path):
continue
state = self._load_simulation_state(sim_id)
if state:
if project_id is None or state.project_id == project_id:
simulations.append(state)
return simulations
def get_profiles(self, simulation_id: str, platform: str = "reddit") -> List[Dict[str, Any]]:
"""获取模拟的Agent Profile"""
"""Get the Agent Profiles of a simulation"""
state = self._load_simulation_state(simulation_id)
if not state:
raise ValueError(f"模拟不存在: {simulation_id}")
raise ValueError(f"Simulation not found: {simulation_id}")
sim_dir = self._get_simulation_dir(simulation_id)
profile_path = os.path.join(sim_dir, f"{platform}_profiles.json")
if not os.path.exists(profile_path):
return []
with open(profile_path, 'r', encoding='utf-8') as f:
return json.load(f)
def get_simulation_config(self, simulation_id: str) -> Optional[Dict[str, Any]]:
"""获取模拟配置"""
"""Get the simulation config"""
sim_dir = self._get_simulation_dir(simulation_id)
config_path = os.path.join(sim_dir, "simulation_config.json")
if not os.path.exists(config_path):
return None
with open(config_path, 'r', encoding='utf-8') as f:
return json.load(f)
def get_run_instructions(self, simulation_id: str) -> Dict[str, str]:
"""获取运行说明"""
"""Get the run instructions"""
sim_dir = self._get_simulation_dir(simulation_id)
config_path = os.path.join(sim_dir, "simulation_config.json")
scripts_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '../../scripts'))
return {
"simulation_dir": sim_dir,
"scripts_dir": scripts_dir,
@ -520,10 +521,10 @@ class SimulationManager:
"parallel": f"python {scripts_dir}/run_parallel_simulation.py --config {config_path}",
},
"instructions": (
f"1. 激活conda环境: conda activate MiroFish\n"
f"2. 运行模拟 (脚本位于 {scripts_dir}):\n"
f" - 单独运行Twitter: python {scripts_dir}/run_twitter_simulation.py --config {config_path}\n"
f" - 单独运行Reddit: python {scripts_dir}/run_reddit_simulation.py --config {config_path}\n"
f" - 并行运行双平台: python {scripts_dir}/run_parallel_simulation.py --config {config_path}"
f"1. Activate the conda environment: conda activate MiroFish\n"
f"2. Run the simulation (scripts are at {scripts_dir}):\n"
f" - Twitter only: python {scripts_dir}/run_twitter_simulation.py --config {config_path}\n"
f" - Reddit only: python {scripts_dir}/run_reddit_simulation.py --config {config_path}\n"
f" - Both platforms in parallel: python {scripts_dir}/run_parallel_simulation.py --config {config_path}"
)
}

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,5 @@
"""
文本处理服务
Text processing service
"""
from typing import List, Optional
@ -7,11 +7,11 @@ from ..utils.file_parser import FileParser, split_text_into_chunks
class TextProcessor:
"""文本处理器"""
"""Text processor"""
@staticmethod
def extract_from_files(file_paths: List[str]) -> str:
"""从多个文件提取文本"""
"""Extract text from multiple files"""
return FileParser.extract_from_multiple(file_paths)
@staticmethod
@ -21,40 +21,40 @@ class TextProcessor:
overlap: int = 50
) -> List[str]:
"""
分割文本
Split text into chunks
Args:
text: 原始文本
chunk_size: 块大小
overlap: 重叠大小
text: raw text
chunk_size: chunk size
overlap: overlap size
Returns:
文本块列表
list of text chunks
"""
return split_text_into_chunks(text, chunk_size, overlap)
@staticmethod
def preprocess_text(text: str) -> str:
"""
预处理文本
- 移除多余空白
- 标准化换行
Preprocess text
- Strip extra whitespace
- Normalize newlines
Args:
text: 原始文本
text: raw text
Returns:
处理后的文本
processed text
"""
import re
# 标准化换行
# Normalize newlines
text = text.replace('\r\n', '\n').replace('\r', '\n')
# 移除连续空行(保留最多两个换行
# Collapse consecutive blank linesKeep at most two newlines in a row
text = re.sub(r'\n{3,}', '\n\n', text)
# 移除行首行尾空白
# Strip leading/trailing whitespace per line
lines = [line.strip() for line in text.split('\n')]
text = '\n'.join(lines)
@ -62,7 +62,7 @@ class TextProcessor:
@staticmethod
def get_text_stats(text: str) -> dict:
"""获取文本统计信息"""
"""Get text statistics"""
return {
"total_chars": len(text),
"total_lines": text.count('\n') + 1,

View File

@ -1,13 +1,24 @@
"""
Zep实体读取与过滤服务
从Zep图谱中读取节点筛选出符合预定义实体类型的节点
Zep entity read & filter service
Reads nodes from the Zep graph and filters out nodes that match
predefined entity types.
"""
import time
from typing import Dict, Any, List, Optional, Set, Callable, TypeVar
from dataclasses import dataclass, field
from zep_cloud.client import Zep
try:
from zep_cloud.client import Zep
except ImportError:
class Zep: # type: ignore[no-redef]
def __init__(self, *a, **kw): pass
class graph:
class node:
@staticmethod
def get_entity_edges(**kw): raise NotImplementedError("zep-cloud not installed; use graphiti_service")
@staticmethod
def get(**kw): raise NotImplementedError("zep-cloud not installed; use graphiti_service")
from ..config import Config
from ..utils.logger import get_logger
@ -15,23 +26,23 @@ from ..utils.zep_paging import fetch_all_nodes, fetch_all_edges
logger = get_logger('mirofish.zep_entity_reader')
# 用于泛型返回类型
# Used for generic return type
T = TypeVar('T')
@dataclass
class EntityNode:
"""实体节点数据结构"""
"""Entity node data structure"""
uuid: str
name: str
labels: List[str]
summary: str
attributes: Dict[str, Any]
# 相关的边信息
# Related edge info
related_edges: List[Dict[str, Any]] = field(default_factory=list)
# 相关的其他节点信息
# Other related-node info
related_nodes: List[Dict[str, Any]] = field(default_factory=list)
def to_dict(self) -> Dict[str, Any]:
return {
"uuid": self.uuid,
@ -42,9 +53,9 @@ class EntityNode:
"related_edges": self.related_edges,
"related_nodes": self.related_nodes,
}
def get_entity_type(self) -> Optional[str]:
"""获取实体类型排除默认的Entity标签"""
"""Get the entity type (excluding the default Entity label)"""
for label in self.labels:
if label not in ["Entity", "Node"]:
return label
@ -53,12 +64,12 @@ class EntityNode:
@dataclass
class FilteredEntities:
"""过滤后的实体集合"""
"""Filtered entity collection"""
entities: List[EntityNode]
entity_types: Set[str]
total_count: int
filtered_count: int
def to_dict(self) -> Dict[str, Any]:
return {
"entities": [e.to_dict() for e in self.entities],
@ -70,43 +81,42 @@ class FilteredEntities:
class ZepEntityReader:
"""
Zep实体读取与过滤服务
主要功能
1. 从Zep图谱读取所有节点
2. 筛选出符合预定义实体类型的节点Labels不只是Entity的节点
3. 获取每个实体的相关边和关联节点信息
Zep entity read & filter service
Main features:
1. Read all nodes from the Zep graph
2. Filter out nodes that match predefined entity types (nodes whose
labels are not just "Entity")
3. Get the related edges and adjacent node info for each entity
"""
def __init__(self, api_key: Optional[str] = None):
self.api_key = api_key or Config.ZEP_API_KEY
if not self.api_key:
raise ValueError("ZEP_API_KEY 未配置")
self.client = Zep(api_key=self.api_key)
self.api_key = api_key # kept for signature compat; no longer required
from .graphiti_service import get_graphiti_adapter
self.client = get_graphiti_adapter()
def _call_with_retry(
self,
func: Callable[[], T],
self,
func: Callable[[], T],
operation_name: str,
max_retries: int = 3,
initial_delay: float = 2.0
) -> T:
"""
带重试机制的Zep API调用
Zep API call with retry logic.
Args:
func: 要执行的函数无参数的lambda或callable
operation_name: 操作名称用于日志
max_retries: 最大重试次数默认3次即最多尝试3次
initial_delay: 初始延迟秒数
func: Function (parameterless lambda or callable) to execute
operation_name: Operation name, used in logs
max_retries: Maximum number of retries (default 3, i.e. at most 3 attempts)
initial_delay: Initial delay in seconds
Returns:
API调用结果
The result of the API call
"""
last_exception = None
delay = initial_delay
for attempt in range(max_retries):
try:
return func()
@ -114,27 +124,27 @@ class ZepEntityReader:
last_exception = e
if attempt < max_retries - 1:
logger.warning(
f"Zep {operation_name} {attempt + 1} 次尝试失败: {str(e)[:100]}, "
f"{delay:.1f}秒后重试..."
f"Zep {operation_name} attempt {attempt + 1} failed: {str(e)[:100]}, "
f"retrying in {delay:.1f}s..."
)
time.sleep(delay)
delay *= 2 # 指数退避
delay *= 2 # Exponential backoff
else:
logger.error(f"Zep {operation_name} {max_retries} 次尝试后仍失败: {str(e)}")
logger.error(f"Zep {operation_name} still failing after {max_retries} attempts: {str(e)}")
raise last_exception
def get_all_nodes(self, graph_id: str) -> List[Dict[str, Any]]:
"""
获取图谱的所有节点分页获取
Get all nodes of the graph (paginated).
Args:
graph_id: 图谱ID
graph_id: Graph ID
Returns:
节点列表
List of nodes
"""
logger.info(f"获取图谱 {graph_id} 的所有节点...")
logger.info(f"Fetching all nodes for graph {graph_id}...")
nodes = fetch_all_nodes(self.client, graph_id)
@ -148,20 +158,20 @@ class ZepEntityReader:
"attributes": node.attributes or {},
})
logger.info(f"共获取 {len(nodes_data)} 个节点")
logger.info(f"Fetched {len(nodes_data)} nodes in total")
return nodes_data
def get_all_edges(self, graph_id: str) -> List[Dict[str, Any]]:
"""
获取图谱的所有边分页获取
Get all edges of the graph (paginated).
Args:
graph_id: 图谱ID
graph_id: Graph ID
Returns:
边列表
List of edges
"""
logger.info(f"获取图谱 {graph_id} 的所有边...")
logger.info(f"Fetching all edges for graph {graph_id}...")
edges = fetch_all_edges(self.client, graph_id)
@ -176,26 +186,26 @@ class ZepEntityReader:
"attributes": edge.attributes or {},
})
logger.info(f"共获取 {len(edges_data)} 条边")
logger.info(f"Fetched {len(edges_data)} edges in total")
return edges_data
def get_node_edges(self, node_uuid: str) -> List[Dict[str, Any]]:
"""
获取指定节点的所有相关边带重试机制
Get all related edges of a given node (with retry logic).
Args:
node_uuid: 节点UUID
node_uuid: Node UUID
Returns:
边列表
List of edges
"""
try:
# 使用重试机制调用Zep API
# Graphiti via adapter
edges = self._call_with_retry(
func=lambda: self.client.graph.node.get_entity_edges(node_uuid=node_uuid),
operation_name=f"获取节点边(node={node_uuid[:8]}...)"
func=lambda: self.client.get_node_edges(node_uuid=node_uuid),
operation_name=f"get_node_edges(node={node_uuid[:8]}...)"
)
edges_data = []
for edge in edges:
edges_data.append({
@ -206,60 +216,63 @@ class ZepEntityReader:
"target_node_uuid": edge.target_node_uuid,
"attributes": edge.attributes or {},
})
return edges_data
except Exception as e:
logger.warning(f"获取节点 {node_uuid} 的边失败: {str(e)}")
logger.warning(f"Failed to fetch edges for node {node_uuid}: {str(e)}")
return []
def filter_defined_entities(
self,
self,
graph_id: str,
defined_entity_types: Optional[List[str]] = None,
enrich_with_edges: bool = True
) -> FilteredEntities:
"""
筛选出符合预定义实体类型的节点
筛选逻辑
- 如果节点的Labels只有一个"Entity"说明这个实体不符合我们预定义的类型跳过
- 如果节点的Labels包含除"Entity""Node"之外的标签说明符合预定义类型保留
Filter out nodes that match predefined entity types.
Filter logic:
- If a node's labels contain only "Entity", it does not match
any of our predefined types and is skipped.
- If a node's labels include something other than "Entity" and
"Node", it matches a predefined type and is kept.
Args:
graph_id: 图谱ID
defined_entity_types: 预定义的实体类型列表可选如果提供则只保留这些类型
enrich_with_edges: 是否获取每个实体的相关边信息
graph_id: Graph ID
defined_entity_types: List of predefined entity types (optional; if
provided, only these types are kept)
enrich_with_edges: Whether to fetch related edges for each entity
Returns:
FilteredEntities: 过滤后的实体集合
FilteredEntities: the filtered entity collection
"""
logger.info(f"开始筛选图谱 {graph_id} 的实体...")
# 获取所有节点
logger.info(f"Starting entity filtering for graph {graph_id}...")
# Get all nodes
all_nodes = self.get_all_nodes(graph_id)
total_count = len(all_nodes)
# 获取所有边(用于后续关联查找)
# Get all edges (used later for adjacency lookup)
all_edges = self.get_all_edges(graph_id) if enrich_with_edges else []
# 构建节点UUID到节点数据的映射
# Build a UUID -> node map
node_map = {n["uuid"]: n for n in all_nodes}
# 筛选符合条件的实体
# Filter entities that meet the criteria
filtered_entities = []
entity_types_found = set()
for node in all_nodes:
labels = node.get("labels", [])
# 筛选逻辑Labels必须包含除"Entity"和"Node"之外的标签
# Filter logic: labels must contain something other than "Entity" and "Node"
custom_labels = [l for l in labels if l not in ["Entity", "Node"]]
if not custom_labels:
# 只有默认标签,跳过
# Only the default labels, skip
continue
# 如果指定了预定义类型,检查是否匹配
# If predefined types are specified, check for a match
if defined_entity_types:
matching_labels = [l for l in custom_labels if l in defined_entity_types]
if not matching_labels:
@ -267,10 +280,10 @@ class ZepEntityReader:
entity_type = matching_labels[0]
else:
entity_type = custom_labels[0]
entity_types_found.add(entity_type)
# 创建实体节点对象
# Build the entity node object
entity = EntityNode(
uuid=node["uuid"],
name=node["name"],
@ -278,12 +291,12 @@ class ZepEntityReader:
summary=node["summary"],
attributes=node["attributes"],
)
# 获取相关边和节点
# Fetch related edges and nodes
if enrich_with_edges:
related_edges = []
related_node_uuids = set()
for edge in all_edges:
if edge["source_node_uuid"] == node["uuid"]:
related_edges.append({
@ -301,10 +314,10 @@ class ZepEntityReader:
"source_node_uuid": edge["source_node_uuid"],
})
related_node_uuids.add(edge["source_node_uuid"])
entity.related_edges = related_edges
# 获取关联节点的基本信息
# Get basic info for related nodes
related_nodes = []
for related_uuid in related_node_uuids:
if related_uuid in node_map:
@ -315,57 +328,57 @@ class ZepEntityReader:
"labels": related_node["labels"],
"summary": related_node.get("summary", ""),
})
entity.related_nodes = related_nodes
filtered_entities.append(entity)
logger.info(f"筛选完成: 总节点 {total_count}, 符合条件 {len(filtered_entities)}, "
f"实体类型: {entity_types_found}")
logger.info(f"Filtering complete: total nodes {total_count}, matching {len(filtered_entities)}, "
f"entity types: {entity_types_found}")
return FilteredEntities(
entities=filtered_entities,
entity_types=entity_types_found,
total_count=total_count,
filtered_count=len(filtered_entities),
)
def get_entity_with_context(
self,
graph_id: str,
self,
graph_id: str,
entity_uuid: str
) -> Optional[EntityNode]:
"""
获取单个实体及其完整上下文边和关联节点带重试机制
Get a single entity with its full context (edges and related nodes, with retry).
Args:
graph_id: 图谱ID
entity_uuid: 实体UUID
graph_id: Graph ID
entity_uuid: Entity UUID
Returns:
EntityNodeNone
EntityNode or None
"""
try:
# 使用重试机制获取节点
# Graphiti via adapter
node = self._call_with_retry(
func=lambda: self.client.graph.node.get(uuid_=entity_uuid),
operation_name=f"获取节点详情(uuid={entity_uuid[:8]}...)"
func=lambda: self.client.get_node(node_uuid=entity_uuid),
operation_name=f"get_node_detail(uuid={entity_uuid[:8]}...)"
)
if not node:
return None
# 获取节点的边
# Get the node's edges
edges = self.get_node_edges(entity_uuid)
# 获取所有节点用于关联查找
# Get all nodes for adjacency lookup
all_nodes = self.get_all_nodes(graph_id)
node_map = {n["uuid"]: n for n in all_nodes}
# 处理相关边和节点
# Process related edges and nodes
related_edges = []
related_node_uuids = set()
for edge in edges:
if edge["source_node_uuid"] == entity_uuid:
related_edges.append({
@ -383,8 +396,8 @@ class ZepEntityReader:
"source_node_uuid": edge["source_node_uuid"],
})
related_node_uuids.add(edge["source_node_uuid"])
# 获取关联节点信息
# Get related node info
related_nodes = []
for related_uuid in related_node_uuids:
if related_uuid in node_map:
@ -395,7 +408,7 @@ class ZepEntityReader:
"labels": related_node["labels"],
"summary": related_node.get("summary", ""),
})
return EntityNode(
uuid=getattr(node, 'uuid_', None) or getattr(node, 'uuid', ''),
name=node.name or "",
@ -405,27 +418,27 @@ class ZepEntityReader:
related_edges=related_edges,
related_nodes=related_nodes,
)
except Exception as e:
logger.error(f"获取实体 {entity_uuid} 失败: {str(e)}")
logger.error(f"Failed to fetch entity {entity_uuid}: {str(e)}")
return None
def get_entities_by_type(
self,
graph_id: str,
self,
graph_id: str,
entity_type: str,
enrich_with_edges: bool = True
) -> List[EntityNode]:
"""
获取指定类型的所有实体
Get all entities of a given type.
Args:
graph_id: 图谱ID
entity_type: 实体类型 "Student", "PublicFigure"
enrich_with_edges: 是否获取相关边信息
graph_id: Graph ID
entity_type: Entity type (e.g. "Student", "PublicFigure")
enrich_with_edges: Whether to fetch related edges
Returns:
实体列表
List of entities
"""
result = self.filter_defined_entities(
graph_id=graph_id,

View File

@ -1,6 +1,6 @@
"""
Zep图谱记忆更新服务
将模拟中的Agent活动动态更新到Zep图谱中
Zep Graph Memory Updater Service
Dynamically updates agent activities from the simulation into the Zep graph
"""
import os
@ -12,7 +12,13 @@ from dataclasses import dataclass
from datetime import datetime
from queue import Queue, Empty
from zep_cloud.client import Zep
try:
from zep_cloud.client import Zep # noqa: F811
except ImportError:
class Zep: # type: ignore[no-redef]
def __init__(self, *a, **kw): pass
class graph:
def add(self, **kw): raise NotImplementedError("zep-cloud not installed; use graphiti_service")
from ..config import Config
from ..utils.logger import get_logger
@ -23,7 +29,7 @@ logger = get_logger('mirofish.zep_graph_memory_updater')
@dataclass
class AgentActivity:
"""Agent活动记录"""
"""Agent activity record"""
platform: str # twitter / reddit
agent_id: int
agent_name: str
@ -31,15 +37,15 @@ class AgentActivity:
action_args: Dict[str, Any]
round_num: int
timestamp: str
def to_episode_text(self) -> str:
"""
将活动转换为可以发送给Zep的文本描述
采用自然语言描述格式让Zep能够从中提取实体和关系
不添加模拟相关的前缀避免误导图谱更新
Convert the activity into a text description that can be sent to Zep.
Uses a natural-language description format so Zep can extract entities and relations from it.
No simulation-related prefix is added to avoid misleading the graph update.
"""
# 根据不同的动作类型生成不同的描述
# Generate a different description for each action type
action_descriptions = {
"CREATE_POST": self._describe_create_post,
"LIKE_POST": self._describe_like_post,
@ -54,226 +60,226 @@ class AgentActivity:
"SEARCH_USER": self._describe_search_user,
"MUTE": self._describe_mute,
}
describe_func = action_descriptions.get(self.action_type, self._describe_generic)
description = describe_func()
# 直接返回 "agent名称: 活动描述" 格式,不添加模拟前缀
# Return directly in "agent_name: activity description" format, no simulation prefix
return f"{self.agent_name}: {description}"
def _describe_create_post(self) -> str:
content = self.action_args.get("content", "")
if content:
return f"发布了一条帖子:「{content}"
return "发布了一条帖子"
return f"published a post: \"{content}\""
return "published a post"
def _describe_like_post(self) -> str:
"""点赞帖子 - 包含帖子原文和作者信息"""
"""Like a post — includes the post text and author info"""
post_content = self.action_args.get("post_content", "")
post_author = self.action_args.get("post_author_name", "")
if post_content and post_author:
return f"点赞了{post_author}的帖子:「{post_content}"
return f"liked {post_author}'s post: \"{post_content}\""
elif post_content:
return f"点赞了一条帖子:「{post_content}"
return f"liked a post: \"{post_content}\""
elif post_author:
return f"点赞了{post_author}的一条帖子"
return "点赞了一条帖子"
return f"liked a post by {post_author}"
return "liked a post"
def _describe_dislike_post(self) -> str:
"""踩帖子 - 包含帖子原文和作者信息"""
"""Dislike a post — includes the post text and author info"""
post_content = self.action_args.get("post_content", "")
post_author = self.action_args.get("post_author_name", "")
if post_content and post_author:
return f"踩了{post_author}的帖子:「{post_content}"
return f"disliked {post_author}'s post: \"{post_content}\""
elif post_content:
return f"踩了一条帖子:「{post_content}"
return f"disliked a post: \"{post_content}\""
elif post_author:
return f"踩了{post_author}的一条帖子"
return "踩了一条帖子"
return f"disliked a post by {post_author}"
return "disliked a post"
def _describe_repost(self) -> str:
"""转发帖子 - 包含原帖内容和作者信息"""
"""Repost — includes the original post text and author info"""
original_content = self.action_args.get("original_content", "")
original_author = self.action_args.get("original_author_name", "")
if original_content and original_author:
return f"转发了{original_author}的帖子:「{original_content}"
return f"reposted {original_author}'s post: \"{original_content}\""
elif original_content:
return f"转发了一条帖子:「{original_content}"
return f"reposted a post: \"{original_content}\""
elif original_author:
return f"转发了{original_author}的一条帖子"
return "转发了一条帖子"
return f"reposted a post by {original_author}"
return "reposted a post"
def _describe_quote_post(self) -> str:
"""引用帖子 - 包含原帖内容、作者信息和引用评论"""
"""Quote a post — includes the original post text, author info, and quote comment"""
original_content = self.action_args.get("original_content", "")
original_author = self.action_args.get("original_author_name", "")
quote_content = self.action_args.get("quote_content", "") or self.action_args.get("content", "")
base = ""
if original_content and original_author:
base = f"引用了{original_author}的帖子「{original_content}"
base = f"quoted {original_author}'s post \"{original_content}\""
elif original_content:
base = f"引用了一条帖子「{original_content}"
base = f"quoted a post \"{original_content}\""
elif original_author:
base = f"引用了{original_author}的一条帖子"
base = f"quoted a post by {original_author}"
else:
base = "引用了一条帖子"
base = "quoted a post"
if quote_content:
base += f",并评论道:「{quote_content}"
base += f", and commented: \"{quote_content}\""
return base
def _describe_follow(self) -> str:
"""关注用户 - 包含被关注用户的名称"""
"""Follow a user — includes the name of the followed user"""
target_user_name = self.action_args.get("target_user_name", "")
if target_user_name:
return f"关注了用户「{target_user_name}"
return "关注了一个用户"
return f"followed user \"{target_user_name}\""
return "followed a user"
def _describe_create_comment(self) -> str:
"""发表评论 - 包含评论内容和所评论的帖子信息"""
"""Post a comment — includes the comment content and the commented post info"""
content = self.action_args.get("content", "")
post_content = self.action_args.get("post_content", "")
post_author = self.action_args.get("post_author_name", "")
if content:
if post_content and post_author:
return f"{post_author}的帖子「{post_content}」下评论道:「{content}"
return f"commented on {post_author}'s post \"{post_content}\": \"{content}\""
elif post_content:
return f"在帖子「{post_content}」下评论道:「{content}"
return f"commented on the post \"{post_content}\": \"{content}\""
elif post_author:
return f"{post_author}的帖子下评论道:「{content}"
return f"评论道:「{content}"
return "发表了评论"
return f"commented on {post_author}'s post: \"{content}\""
return f"commented: \"{content}\""
return "posted a comment"
def _describe_like_comment(self) -> str:
"""点赞评论 - 包含评论内容和作者信息"""
"""Like a comment — includes the comment text and author info"""
comment_content = self.action_args.get("comment_content", "")
comment_author = self.action_args.get("comment_author_name", "")
if comment_content and comment_author:
return f"点赞了{comment_author}的评论:「{comment_content}"
return f"liked {comment_author}'s comment: \"{comment_content}\""
elif comment_content:
return f"点赞了一条评论:「{comment_content}"
return f"liked a comment: \"{comment_content}\""
elif comment_author:
return f"点赞了{comment_author}的一条评论"
return "点赞了一条评论"
return f"liked a comment by {comment_author}"
return "liked a comment"
def _describe_dislike_comment(self) -> str:
"""踩评论 - 包含评论内容和作者信息"""
"""Dislike a comment — includes the comment text and author info"""
comment_content = self.action_args.get("comment_content", "")
comment_author = self.action_args.get("comment_author_name", "")
if comment_content and comment_author:
return f"踩了{comment_author}的评论:「{comment_content}"
return f"disliked {comment_author}'s comment: \"{comment_content}\""
elif comment_content:
return f"踩了一条评论:「{comment_content}"
return f"disliked a comment: \"{comment_content}\""
elif comment_author:
return f"踩了{comment_author}的一条评论"
return "踩了一条评论"
return f"disliked a comment by {comment_author}"
return "disliked a comment"
def _describe_search(self) -> str:
"""搜索帖子 - 包含搜索关键词"""
"""Search posts — includes the search keyword"""
query = self.action_args.get("query", "") or self.action_args.get("keyword", "")
return f"搜索了「{query}" if query else "进行了搜索"
return f"searched \"{query}\"" if query else "performed a search"
def _describe_search_user(self) -> str:
"""搜索用户 - 包含搜索关键词"""
"""Search users — includes the search keyword"""
query = self.action_args.get("query", "") or self.action_args.get("username", "")
return f"搜索了用户「{query}" if query else "搜索了用户"
return f"searched for user \"{query}\"" if query else "searched for a user"
def _describe_mute(self) -> str:
"""屏蔽用户 - 包含被屏蔽用户的名称"""
"""Mute a user — includes the name of the muted user"""
target_user_name = self.action_args.get("target_user_name", "")
if target_user_name:
return f"屏蔽了用户「{target_user_name}"
return "屏蔽了一个用户"
return f"muted user \"{target_user_name}\""
return "muted a user"
def _describe_generic(self) -> str:
# 对于未知的动作类型,生成通用描述
return f"执行了{self.action_type}操作"
# For unknown action types, generate a generic description
return f"performed {self.action_type} operation"
class ZepGraphMemoryUpdater:
"""
Zep图谱记忆更新器
监控模拟的actions日志文件将新的agent活动实时更新到Zep图谱中
按平台分组每累积BATCH_SIZE条活动后批量发送到Zep
所有有意义的行为都会被更新到Zepaction_args中会包含完整的上下文信息
- 点赞/踩的帖子原文
- 转发/引用的帖子原文
- 关注/屏蔽的用户名
- 点赞/踩的评论原文
Zep graph memory updater.
Monitors the simulation's actions log file and updates new agent activities
into the Zep graph in real time. Activities are grouped by platform, and
a batch is sent to Zep once BATCH_SIZE items have accumulated.
All meaningful behaviors are pushed to Zep; action_args carries the full
context for each one:
- the original text of liked/disliked posts
- the original text of reposted/quoted posts
- the name of followed/muted users
- the original text of liked/disliked comments
"""
# 批量发送大小(每个平台累积多少条后发送)
# Batch send size (how many activities to accumulate per platform before sending)
BATCH_SIZE = 5
# 平台名称映射(用于控制台显示)
# Platform name mapping (for console display)
PLATFORM_DISPLAY_NAMES = {
'twitter': '世界1',
'reddit': '世界2',
'twitter': 'World 1',
'reddit': 'World 2',
}
# 发送间隔(秒),避免请求过快
# Send interval (seconds) to avoid hitting the API too fast
SEND_INTERVAL = 0.5
# 重试配置
# Retry configuration
MAX_RETRIES = 3
RETRY_DELAY = 2 #
RETRY_DELAY = 2 # seconds
def __init__(self, graph_id: str, api_key: Optional[str] = None):
"""
初始化更新器
Initialize the updater.
Args:
graph_id: Zep图谱ID
api_key: Zep API Key可选默认从配置读取
graph_id: Zep graph ID
api_key: Zep API key (optional; read from config by default)
"""
self.graph_id = graph_id
self.api_key = api_key or Config.ZEP_API_KEY
if not self.api_key:
raise ValueError("ZEP_API_KEY未配置")
self.client = Zep(api_key=self.api_key)
# 活动队列
self.api_key = api_key # kept for signature compat; no longer required
from .graphiti_service import get_graphiti_adapter
self.client = get_graphiti_adapter()
# Activity queue
self._activity_queue: Queue = Queue()
# 按平台分组的活动缓冲区每个平台各自累积到BATCH_SIZE后批量发送
# Per-platform activity buffer (each platform accumulates to BATCH_SIZE then sends)
self._platform_buffers: Dict[str, List[AgentActivity]] = {
'twitter': [],
'reddit': [],
}
self._buffer_lock = threading.Lock()
# 控制标志
# Control flags
self._running = False
self._worker_thread: Optional[threading.Thread] = None
# 统计
self._total_activities = 0 # 实际添加到队列的活动数
self._total_sent = 0 # 成功发送到Zep的批次数
self._total_items_sent = 0 # 成功发送到Zep的活动条数
self._failed_count = 0 # 发送失败的批次数
self._skipped_count = 0 # 被过滤跳过的活动数DO_NOTHING
logger.info(f"ZepGraphMemoryUpdater 初始化完成: graph_id={graph_id}, batch_size={self.BATCH_SIZE}")
# Stats
self._total_activities = 0 # number of activities actually added to the queue
self._total_sent = 0 # number of batches successfully sent to Zep
self._total_items_sent = 0 # number of activities successfully sent to Zep
self._failed_count = 0 # number of failed batches
self._skipped_count = 0 # activities filtered out (DO_NOTHING)
logger.info(f"ZepGraphMemoryUpdater initialization complete: graph_id={graph_id}, batch_size={self.BATCH_SIZE}")
def _get_platform_display_name(self, platform: str) -> str:
"""获取平台的显示名称"""
"""Get the display name of a platform"""
return self.PLATFORM_DISPLAY_NAMES.get(platform.lower(), platform)
def start(self):
"""启动后台工作线程"""
"""Start the background worker thread"""
if self._running:
return
@ -288,19 +294,19 @@ class ZepGraphMemoryUpdater:
name=f"ZepMemoryUpdater-{self.graph_id[:8]}"
)
self._worker_thread.start()
logger.info(f"ZepGraphMemoryUpdater 已启动: graph_id={self.graph_id}")
logger.info(f"ZepGraphMemoryUpdater started: graph_id={self.graph_id}")
def stop(self):
"""停止后台工作线程"""
"""Stop the background worker thread"""
self._running = False
# 发送剩余的活动
# Flush remaining activities
self._flush_remaining()
if self._worker_thread and self._worker_thread.is_alive():
self._worker_thread.join(timeout=10)
logger.info(f"ZepGraphMemoryUpdater 已停止: graph_id={self.graph_id}, "
logger.info(f"ZepGraphMemoryUpdater stopped: graph_id={self.graph_id}, "
f"total_activities={self._total_activities}, "
f"batches_sent={self._total_sent}, "
f"items_sent={self._total_items_sent}, "
@ -309,46 +315,46 @@ class ZepGraphMemoryUpdater:
def add_activity(self, activity: AgentActivity):
"""
添加一个agent活动到队列
所有有意义的行为都会被添加到队列包括
- CREATE_POST发帖
- CREATE_COMMENT评论
- QUOTE_POST引用帖子
- SEARCH_POSTS搜索帖子
- SEARCH_USER搜索用户
- LIKE_POST/DISLIKE_POST点赞/踩帖子
- REPOST转发
- FOLLOW关注
- MUTE屏蔽
- LIKE_COMMENT/DISLIKE_COMMENT点赞/踩评论
action_args中会包含完整的上下文信息如帖子原文用户名等
Add an agent activity to the queue.
All meaningful behaviors are added to the queue, including:
- CREATE_POST (publish a post)
- CREATE_COMMENT (post a comment)
- QUOTE_POST (quote a post)
- SEARCH_POSTS (search posts)
- SEARCH_USER (search users)
- LIKE_POST/DISLIKE_POST (like/dislike a post)
- REPOST (repost)
- FOLLOW (follow)
- MUTE (mute)
- LIKE_COMMENT/DISLIKE_COMMENT (like/dislike a comment)
action_args carries the full context (original post text, username, etc.).
Args:
activity: Agent活动记录
activity: Agent activity record
"""
# 跳过DO_NOTHING类型的活动
# Skip DO_NOTHING activities
if activity.action_type == "DO_NOTHING":
self._skipped_count += 1
return
self._activity_queue.put(activity)
self._total_activities += 1
logger.debug(f"添加活动到Zep队列: {activity.agent_name} - {activity.action_type}")
logger.debug(f"Added activity to Zep queue: {activity.agent_name} - {activity.action_type}")
def add_activity_from_dict(self, data: Dict[str, Any], platform: str):
"""
从字典数据添加活动
Add an activity from a dictionary.
Args:
data: 从actions.jsonl解析的字典数据
platform: 平台名称 (twitter/reddit)
data: Dictionary parsed from actions.jsonl
platform: Platform name (twitter/reddit)
"""
# 跳过事件类型的条目
# Skip entries that are events
if "event_type" in data:
return
activity = AgentActivity(
platform=platform,
agent_id=data.get("agent_id", 0),
@ -358,83 +364,83 @@ class ZepGraphMemoryUpdater:
round_num=data.get("round", 0),
timestamp=data.get("timestamp", datetime.now().isoformat()),
)
self.add_activity(activity)
def _worker_loop(self, locale: str = 'zh'):
"""后台工作循环 - 按平台批量发送活动到Zep"""
"""Background worker loop — batch-sends activities to Zep per platform"""
set_locale(locale)
while self._running or not self._activity_queue.empty():
try:
# 尝试从队列获取活动超时1秒
# Try to fetch an activity from the queue (1s timeout)
try:
activity = self._activity_queue.get(timeout=1)
# 将活动添加到对应平台的缓冲区
# Append the activity to the corresponding platform buffer
platform = activity.platform.lower()
with self._buffer_lock:
if platform not in self._platform_buffers:
self._platform_buffers[platform] = []
self._platform_buffers[platform].append(activity)
# 检查该平台是否达到批量大小
# Check if this platform reached the batch size
if len(self._platform_buffers[platform]) >= self.BATCH_SIZE:
batch = self._platform_buffers[platform][:self.BATCH_SIZE]
self._platform_buffers[platform] = self._platform_buffers[platform][self.BATCH_SIZE:]
# 释放锁后再发送
# Release the lock before sending
self._send_batch_activities(batch, platform)
# 发送间隔,避免请求过快
# Send interval to avoid hitting the API too fast
time.sleep(self.SEND_INTERVAL)
except Empty:
pass
except Exception as e:
logger.error(f"工作循环异常: {e}")
logger.error(f"Worker loop error: {e}")
time.sleep(1)
def _send_batch_activities(self, activities: List[AgentActivity], platform: str):
"""
批量发送活动到Zep图谱合并为一条文本
Batch-send activities to the Zep graph (merged into a single text).
Args:
activities: Agent活动列表
platform: 平台名称
activities: List of agent activities
platform: Platform name
"""
if not activities:
return
# 将多条活动合并为一条文本,用换行分隔
# Merge multiple activities into one text, separated by newlines
episode_texts = [activity.to_episode_text() for activity in activities]
combined_text = "\n".join(episode_texts)
# 带重试的发送
# Send with retry
for attempt in range(self.MAX_RETRIES):
try:
self.client.graph.add(
# Graphiti via adapter: one episode per batch (extraction is sync)
self.client.add_batch(
graph_id=self.graph_id,
type="text",
data=combined_text
episodes=[{"data": combined_text, "type": "text"}],
)
self._total_sent += 1
self._total_items_sent += len(activities)
display_name = self._get_platform_display_name(platform)
logger.info(f"成功批量发送 {len(activities)}{display_name}活动到图谱 {self.graph_id}")
logger.debug(f"批量内容预览: {combined_text[:200]}...")
logger.info(f"Successfully batch-sent {len(activities)} {display_name} activities to graph {self.graph_id}")
logger.debug(f"Batch content preview: {combined_text[:200]}...")
return
except Exception as e:
if attempt < self.MAX_RETRIES - 1:
logger.warning(f"批量发送到Zep失败 (尝试 {attempt + 1}/{self.MAX_RETRIES}): {e}")
logger.warning(f"Batch send to Zep failed (attempt {attempt + 1}/{self.MAX_RETRIES}): {e}")
time.sleep(self.RETRY_DELAY * (attempt + 1))
else:
logger.error(f"批量发送到Zep失败已重试{self.MAX_RETRIES}: {e}")
logger.error(f"Batch send to Zep failed after {self.MAX_RETRIES} retries: {e}")
self._failed_count += 1
def _flush_remaining(self):
"""发送队列和缓冲区中剩余的活动"""
# 首先处理队列中剩余的活动,添加到缓冲区
"""Flush remaining activities in the queue and buffers"""
# First, drain the queue into the buffers
while not self._activity_queue.empty():
try:
activity = self._activity_queue.get_nowait()
@ -445,110 +451,110 @@ class ZepGraphMemoryUpdater:
self._platform_buffers[platform].append(activity)
except Empty:
break
# 然后发送各平台缓冲区中剩余的活动即使不足BATCH_SIZE条
# Then send any remaining activities in the per-platform buffers (even if < BATCH_SIZE)
with self._buffer_lock:
for platform, buffer in self._platform_buffers.items():
if buffer:
display_name = self._get_platform_display_name(platform)
logger.info(f"发送{display_name}平台剩余的 {len(buffer)} 条活动")
logger.info(f"Flushing remaining {len(buffer)} activities from {display_name}")
self._send_batch_activities(buffer, platform)
# 清空所有缓冲区
# Clear all buffers
for platform in self._platform_buffers:
self._platform_buffers[platform] = []
def get_stats(self) -> Dict[str, Any]:
"""获取统计信息"""
"""Get stats"""
with self._buffer_lock:
buffer_sizes = {p: len(b) for p, b in self._platform_buffers.items()}
return {
"graph_id": self.graph_id,
"batch_size": self.BATCH_SIZE,
"total_activities": self._total_activities, # 添加到队列的活动总数
"batches_sent": self._total_sent, # 成功发送的批次数
"items_sent": self._total_items_sent, # 成功发送的活动条数
"failed_count": self._failed_count, # 发送失败的批次数
"skipped_count": self._skipped_count, # 被过滤跳过的活动数DO_NOTHING
"total_activities": self._total_activities, # total activities added to the queue
"batches_sent": self._total_sent, # successfully sent batches
"items_sent": self._total_items_sent, # successfully sent activities
"failed_count": self._failed_count, # failed batches
"skipped_count": self._skipped_count, # activities filtered out (DO_NOTHING)
"queue_size": self._activity_queue.qsize(),
"buffer_sizes": buffer_sizes, # 各平台缓冲区大小
"buffer_sizes": buffer_sizes, # per-platform buffer sizes
"running": self._running,
}
class ZepGraphMemoryManager:
"""
管理多个模拟的Zep图谱记忆更新器
每个模拟可以有自己的更新器实例
Manages Zep graph memory updaters for multiple simulations.
Each simulation can have its own updater instance.
"""
_updaters: Dict[str, ZepGraphMemoryUpdater] = {}
_lock = threading.Lock()
@classmethod
def create_updater(cls, simulation_id: str, graph_id: str) -> ZepGraphMemoryUpdater:
"""
为模拟创建图谱记忆更新器
Create a graph memory updater for a simulation.
Args:
simulation_id: 模拟ID
graph_id: Zep图谱ID
simulation_id: Simulation ID
graph_id: Zep graph ID
Returns:
ZepGraphMemoryUpdater实例
A ZepGraphMemoryUpdater instance
"""
with cls._lock:
# 如果已存在,先停止旧的
# If one already exists, stop it first
if simulation_id in cls._updaters:
cls._updaters[simulation_id].stop()
updater = ZepGraphMemoryUpdater(graph_id)
updater.start()
cls._updaters[simulation_id] = updater
logger.info(f"创建图谱记忆更新器: simulation_id={simulation_id}, graph_id={graph_id}")
logger.info(f"Created graph memory updater: simulation_id={simulation_id}, graph_id={graph_id}")
return updater
@classmethod
def get_updater(cls, simulation_id: str) -> Optional[ZepGraphMemoryUpdater]:
"""获取模拟的更新器"""
"""Get the updater for a simulation"""
return cls._updaters.get(simulation_id)
@classmethod
def stop_updater(cls, simulation_id: str):
"""停止并移除模拟的更新器"""
"""Stop and remove the updater for a simulation"""
with cls._lock:
if simulation_id in cls._updaters:
cls._updaters[simulation_id].stop()
del cls._updaters[simulation_id]
logger.info(f"已停止图谱记忆更新器: simulation_id={simulation_id}")
# 防止 stop_all 重复调用的标志
logger.info(f"Stopped graph memory updater: simulation_id={simulation_id}")
# Flag to prevent repeated stop_all calls
_stop_all_done = False
@classmethod
def stop_all(cls):
"""停止所有更新器"""
# 防止重复调用
"""Stop all updaters"""
# Prevent repeated calls
if cls._stop_all_done:
return
cls._stop_all_done = True
with cls._lock:
if cls._updaters:
for simulation_id, updater in list(cls._updaters.items()):
try:
updater.stop()
except Exception as e:
logger.error(f"停止更新器失败: simulation_id={simulation_id}, error={e}")
logger.error(f"Failed to stop updater: simulation_id={simulation_id}, error={e}")
cls._updaters.clear()
logger.info("已停止所有图谱记忆更新器")
logger.info("Stopped all graph memory updaters")
@classmethod
def get_all_stats(cls) -> Dict[str, Dict[str, Any]]:
"""获取所有更新器的统计信息"""
"""Get stats for all updaters"""
return {
sim_id: updater.get_stats()
sim_id: updater.get_stats()
for sim_id, updater in cls._updaters.items()
}

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,5 @@
"""
工具模块
Utility module.
"""
from .file_parser import FileParser
@ -7,4 +7,3 @@ from .llm_client import LLMClient
from .locale import t, get_locale, set_locale, get_language_instruction
__all__ = ['FileParser', 'LLMClient', 't', 'get_locale', 'set_locale', 'get_language_instruction']

View File

@ -1,6 +1,6 @@
"""
文件解析工具
支持PDFMarkdownTXT文件的文本提取
File parser utilities
Supports text extraction from PDF, Markdown, and TXT files
"""
import os
@ -10,29 +10,29 @@ from typing import List, Optional
def _read_text_with_fallback(file_path: str) -> str:
"""
读取文本文件UTF-8失败时自动探测编码
Read a text file with UTF-8; auto-detect encoding on failure.
采用多级回退策略
1. 首先尝试 UTF-8 解码
2. 使用 charset_normalizer 检测编码
3. 回退到 chardet 检测编码
4. 最终使用 UTF-8 + errors='replace' 兜底
Uses a multi-level fallback strategy:
1. 1. First try UTF-8 decoding
2. 2. Use charset_normalizer to detect encoding
3. 3. Fall back to chardet
4. 4. Final fallback: UTF-8 with errors="replace"
Args:
file_path: 文件路径
file_path: file path
Returns:
解码后的文本内容
decoded text content
"""
data = Path(file_path).read_bytes()
# 首先尝试 UTF-8
# First try UTF-8
try:
return data.decode('utf-8')
except UnicodeDecodeError:
pass
# 尝试使用 charset_normalizer 检测编码
# 2. Use charset_normalizer to detect encoding
encoding = None
try:
from charset_normalizer import from_bytes
@ -42,7 +42,7 @@ def _read_text_with_fallback(file_path: str) -> str:
except Exception:
pass
# 回退到 chardet
# Fall back to chardet
if not encoding:
try:
import chardet
@ -51,7 +51,7 @@ def _read_text_with_fallback(file_path: str) -> str:
except Exception:
pass
# 最终兜底:使用 UTF-8 + replace
# Final fallback: UTF-8 with replace
if not encoding:
encoding = 'utf-8'
@ -59,20 +59,20 @@ def _read_text_with_fallback(file_path: str) -> str:
class FileParser:
"""文件解析器"""
"""File parser"""
SUPPORTED_EXTENSIONS = {'.pdf', '.md', '.markdown', '.txt'}
@classmethod
def is_supported(cls, file_path: str) -> bool:
"""
检查文件是否为支持的格式
Check whether a file is in a supported format
Args:
file_path: 文件路径
file_path: file path
Returns:
如果文件格式受支持则返回 True
Return True if the file format is supported
"""
suffix = Path(file_path).suffix.lower()
return suffix in cls.SUPPORTED_EXTENSIONS
@ -80,23 +80,23 @@ class FileParser:
@classmethod
def extract_text(cls, file_path: str) -> str:
"""
从文件中提取文本
Extract text from a file
Args:
file_path: 文件路径
file_path: file path
Returns:
提取的文本内容
extracted text content
"""
path = Path(file_path)
if not path.exists():
raise FileNotFoundError(f"文件不存在: {file_path}")
raise FileNotFoundError(f"File does not exist: {file_path}")
suffix = path.suffix.lower()
if suffix not in cls.SUPPORTED_EXTENSIONS:
raise ValueError(f"不支持的文件格式: {suffix}")
raise ValueError(f"Unsupported file format: {suffix}")
if suffix == '.pdf':
return cls._extract_from_pdf(file_path)
@ -105,15 +105,15 @@ class FileParser:
elif suffix == '.txt':
return cls._extract_from_txt(file_path)
raise ValueError(f"无法处理的文件格式: {suffix}")
raise ValueError(f"Cannot process file format: {suffix}")
@staticmethod
def _extract_from_pdf(file_path: str) -> str:
"""从PDF提取文本"""
"""Extract text from PDF"""
try:
import fitz # PyMuPDF
except ImportError:
raise ImportError("需要安装PyMuPDF: pip install PyMuPDF")
raise ImportError("PyMuPDF is required: pip install PyMuPDF")
text_parts = []
with fitz.open(file_path) as doc:
@ -126,24 +126,24 @@ class FileParser:
@staticmethod
def _extract_from_md(file_path: str) -> str:
"""从Markdown提取文本支持自动编码检测"""
"""Extract text from Markdown with auto-encoding detection"""
return _read_text_with_fallback(file_path)
@staticmethod
def _extract_from_txt(file_path: str) -> str:
"""从TXT提取文本支持自动编码检测"""
"""Extract text from TXT with auto-encoding detection"""
return _read_text_with_fallback(file_path)
@classmethod
def extract_from_multiple(cls, file_paths: List[str]) -> str:
"""
从多个文件提取文本并合并
Extract and merge text from multiple files
Args:
file_paths: 文件路径列表
file_paths: list of file paths
Returns:
合并后的文本
merged text
"""
all_texts = []
@ -151,9 +151,9 @@ class FileParser:
try:
text = cls.extract_text(file_path)
filename = Path(file_path).name
all_texts.append(f"=== 文档 {i}: {filename} ===\n{text}")
all_texts.append(f"=== Document {i}: {filename} ===\n{text}")
except Exception as e:
all_texts.append(f"=== 文档 {i}: {file_path} (提取失败: {str(e)}) ===")
all_texts.append(f"=== Document {i}: {file_path} (extraction failed: {str(e)}) ===")
return "\n\n".join(all_texts)
@ -164,15 +164,15 @@ def split_text_into_chunks(
overlap: int = 50
) -> List[str]:
"""
将文本分割成小块
Split text into chunks
Args:
text: 原始文本
chunk_size: 每块的字符数
overlap: 重叠字符数
text: raw text
chunk_size: characters per chunk
overlap: overlap character count
Returns:
文本块列表
list of text chunks
"""
if len(text) <= chunk_size:
return [text] if text.strip() else []
@ -183,9 +183,9 @@ def split_text_into_chunks(
while start < len(text):
end = start + chunk_size
# 尝试在句子边界处分割
# Try to split on sentence boundaries
if end < len(text):
# 查找最近的句子结束符
# Find the nearest sentence-ending punctuation
for sep in ['', '', '', '.\n', '!\n', '?\n', '\n\n', '. ', '! ', '? ']:
last_sep = text[start:end].rfind(sep)
if last_sep != -1 and last_sep > chunk_size * 0.3:
@ -196,7 +196,7 @@ def split_text_into_chunks(
if chunk:
chunks.append(chunk)
# 下一个块从重叠位置开始
# The next chunk starts at the overlap position
start = end - overlap if end < len(text) else len(text)
return chunks

View File

@ -1,6 +1,6 @@
"""
LLM客户端封装
统一使用OpenAI格式调用
LLM client wrapper
Unified OpenAI-format invocation
"""
import json
@ -12,7 +12,7 @@ from ..config import Config
class LLMClient:
"""LLM客户端"""
"""LLM client"""
def __init__(
self,
@ -25,7 +25,7 @@ class LLMClient:
self.model = model or Config.LLM_MODEL_NAME
if not self.api_key:
raise ValueError("LLM_API_KEY 未配置")
raise ValueError("LLM_API_KEY is not configured")
self.client = OpenAI(
api_key=self.api_key,
@ -40,16 +40,16 @@ class LLMClient:
response_format: Optional[Dict] = None
) -> str:
"""
发送聊天请求
Send a chat completion request
Args:
messages: 消息列表
temperature: 温度参数
max_tokens: 最大token数
response_format: 响应格式如JSON模式
messages: message list
temperature: sampling temperature
max_tokens: max tokens
response_format: response formate.g. JSON mode
Returns:
模型响应文本
model response text
"""
kwargs = {
"model": self.model,
@ -63,7 +63,7 @@ class LLMClient:
response = self.client.chat.completions.create(**kwargs)
content = response.choices[0].message.content
# 部分模型如MiniMax M2.5会在content中包含<think>思考内容,需要移除
# Some models (e.g. MiniMax M2.5) embed <think>...</think> reasoning in content — strip it
content = re.sub(r'<think>[\s\S]*?</think>', '', content).strip()
return content
@ -71,18 +71,19 @@ class LLMClient:
self,
messages: List[Dict[str, str]],
temperature: float = 0.3,
max_tokens: int = 4096
max_tokens: int = 16384
) -> Dict[str, Any]:
"""
发送聊天请求并返回JSON
Send a chat completion requestand parse the response as JSON
Args:
messages: 消息列表
temperature: 温度参数
max_tokens: 最大token数
messages: message list
temperature: sampling temperature
max_tokens: max tokens (default 16384 MiniMax M3 in ontology / config
long-JSON mode 4096 tokens frequently truncates the JSON in the middle)
Returns:
解析后的JSON对象
parsed JSON object
"""
response = self.chat(
messages=messages,
@ -90,14 +91,25 @@ class LLMClient:
max_tokens=max_tokens,
response_format={"type": "json_object"}
)
# 清理markdown代码块标记
# Strip markdown code-block fences
# MiniMax M3 ignores response_format=json_object and wraps JSON in
# markdown fences. Be aggressive about stripping them, including the
# ```json\n and trailing ``` even when surrounded by other content.
cleaned_response = response.strip()
cleaned_response = re.sub(r'^```(?:json)?\s*\n?', '', cleaned_response, flags=re.IGNORECASE)
# Strip a leading ``` (with optional language tag and newline)
cleaned_response = re.sub(r'^```(?:json|JSON)?\s*\n?', '', cleaned_response, flags=re.IGNORECASE)
# Strip a trailing ``` (with optional preceding newline)
cleaned_response = re.sub(r'\n?```\s*$', '', cleaned_response)
# If there's still a ```json or ``` anywhere, take the first JSON-looking
# block from the response.
if '```' in cleaned_response:
m = re.search(r'(\{[\s\S]*\}|\[[\s\S]*\])', cleaned_response)
if m:
cleaned_response = m.group(1)
cleaned_response = cleaned_response.strip()
try:
return json.loads(cleaned_response)
except json.JSONDecodeError:
raise ValueError(f"LLM返回的JSON格式无效: {cleaned_response}")
raise ValueError(f"LLM returned invalid JSON: {cleaned_response[:200]}")

View File

@ -1,3 +1,9 @@
"""
Locale / i18n helpers used by the backend. Loads translation files from
``/opt/data/work/MiroFish/locales`` and exposes ``t(key)`` lookups plus a
per-thread locale setting.
"""
import json
import os
import threading
@ -64,6 +70,10 @@ def t(key: str, **kwargs) -> str:
def get_language_instruction() -> str:
"""
Return the LLM language-injection string for the active locale.
Falls back to a Chinese instruction if no configuration is found.
"""
locale = get_locale()
lang_config = _languages.get(locale, _languages.get('zh', {}))
return lang_config.get('llmInstruction', '请使用中文回答。')
return lang_config.get('llmInstruction', 'Please reply in English.')

View File

@ -1,6 +1,6 @@
"""
日志配置模块
提供统一的日志管理同时输出到控制台和文件
Logging configuration module
Provides unified log management, output to both console and file
"""
import os
@ -12,47 +12,47 @@ from logging.handlers import RotatingFileHandler
def _ensure_utf8_stdout():
"""
确保 stdout/stderr 使用 UTF-8 编码
解决 Windows 控制台中文乱码问题
Ensure stdout/stderr use UTF-8
Work around Windows console garbled Chinese
"""
if sys.platform == 'win32':
# Windows 下重新配置标准输出为 UTF-8
# Reconfigure stdout to UTF-8 on Windows
if hasattr(sys.stdout, 'reconfigure'):
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
if hasattr(sys.stderr, 'reconfigure'):
sys.stderr.reconfigure(encoding='utf-8', errors='replace')
# 日志目录
# Log directory
LOG_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), 'logs')
def setup_logger(name: str = 'mirofish', level: int = logging.DEBUG) -> logging.Logger:
"""
设置日志器
Set up a logger
Args:
name: 日志器名称
level: 日志级别
name: logger name
level: log level
Returns:
配置好的日志器
configured logger
"""
# 确保日志目录存在
# Ensure log directory exists
os.makedirs(LOG_DIR, exist_ok=True)
# 创建日志器
# Create logger
logger = logging.getLogger(name)
logger.setLevel(level)
# 阻止日志向上传播到根 logger避免重复输出
# Prevent logs from propagating to the root logger (avoid double output)
logger.propagate = False
# 如果已经有处理器,不重复添加
# If a handler is already attached, do not add another
if logger.handlers:
return logger
# 日志格式
# Log format
detailed_formatter = logging.Formatter(
'[%(asctime)s] %(levelname)s [%(name)s.%(funcName)s:%(lineno)d] %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
@ -63,7 +63,7 @@ def setup_logger(name: str = 'mirofish', level: int = logging.DEBUG) -> logging.
datefmt='%H:%M:%S'
)
# 1. 文件处理器 - 详细日志(按日期命名,带轮转)
# 1. File handler — verbose log (date-named, with rotation)
log_filename = datetime.now().strftime('%Y-%m-%d') + '.log'
file_handler = RotatingFileHandler(
os.path.join(LOG_DIR, log_filename),
@ -74,14 +74,14 @@ def setup_logger(name: str = 'mirofish', level: int = logging.DEBUG) -> logging.
file_handler.setLevel(logging.DEBUG)
file_handler.setFormatter(detailed_formatter)
# 2. 控制台处理器 - 简洁日志INFO及以上
# 确保 Windows 下使用 UTF-8 编码,避免中文乱码
# 2. Console handler — concise log (INFO and above)
# Use UTF-8 on Windows to avoid garbled output
_ensure_utf8_stdout()
console_handler = logging.StreamHandler(sys.stdout)
console_handler.setLevel(logging.INFO)
console_handler.setFormatter(simple_formatter)
# 添加处理器
# Add handler
logger.addHandler(file_handler)
logger.addHandler(console_handler)
@ -90,13 +90,13 @@ def setup_logger(name: str = 'mirofish', level: int = logging.DEBUG) -> logging.
def get_logger(name: str = 'mirofish') -> logging.Logger:
"""
获取日志器如果不存在则创建
Get a logger (creating it if it does not exist)
Args:
name: 日志器名称
name: logger name
Returns:
日志器实例
logger instance
"""
logger = logging.getLogger(name)
if not logger.handlers:
@ -104,11 +104,11 @@ def get_logger(name: str = 'mirofish') -> logging.Logger:
return logger
# 创建默认日志器
# Create the default logger
logger = setup_logger()
# 便捷方法
# Convenience methods
def debug(msg: str, *args, **kwargs) -> None:
logger.debug(msg, *args, **kwargs)

View File

@ -1,6 +1,6 @@
"""
API调用重试机制
用于处理LLM等外部API调用的重试逻辑
API-call retry mechanism
Handles retry logic for external API calls (e.g. LLM)
"""
import time
@ -22,16 +22,16 @@ def retry_with_backoff(
on_retry: Optional[Callable[[Exception, int], None]] = None
):
"""
带指数退避的重试装饰器
Retry decorator with exponential backoff
Args:
max_retries: 最大重试次数
initial_delay: 初始延迟
max_delay: 最大延迟
backoff_factor: 退避因子
jitter: 是否添加随机抖动
exceptions: 需要重试的异常类型
on_retry: 重试时的回调函数 (exception, retry_count)
max_retries: maximum retry count
initial_delay: initial delay (seconds)
max_delay: maximum delay (seconds)
backoff_factor: backoff factor
jitter: whether to add random jitter
exceptions: exception types to retry on
on_retry: callback invoked on each retry (exception, retry_count)
Usage:
@retry_with_backoff(max_retries=3)
@ -52,17 +52,17 @@ def retry_with_backoff(
last_exception = e
if attempt == max_retries:
logger.error(f"函数 {func.__name__}{max_retries} 次重试后仍失败: {str(e)}")
logger.error(f"Function {func.__name__} still failed after {max_retries} retries: {str(e)}")
raise
# 计算延迟
# Calculate delay
current_delay = min(delay, max_delay)
if jitter:
current_delay = current_delay * (0.5 + random.random())
logger.warning(
f"函数 {func.__name__}{attempt + 1} 次尝试失败: {str(e)}, "
f"{current_delay:.1f}秒后重试..."
f"function {func.__name__} attempt {attempt + 1} failed: {str(e)}, "
f"{current_delay:.1f}s, retrying..."
)
if on_retry:
@ -87,7 +87,7 @@ def retry_with_backoff_async(
on_retry: Optional[Callable[[Exception, int], None]] = None
):
"""
异步版本的重试装饰器
Async version of the retry decorator
"""
import asyncio
@ -105,7 +105,7 @@ def retry_with_backoff_async(
last_exception = e
if attempt == max_retries:
logger.error(f"异步函数 {func.__name__}{max_retries} 次重试后仍失败: {str(e)}")
logger.error(f"async function {func.__name__} still failed after {max_retries} retries: {str(e)}")
raise
current_delay = min(delay, max_delay)
@ -113,8 +113,8 @@ def retry_with_backoff_async(
current_delay = current_delay * (0.5 + random.random())
logger.warning(
f"异步函数 {func.__name__}{attempt + 1} 次尝试失败: {str(e)}, "
f"{current_delay:.1f}秒后重试..."
f"async function {func.__name__} attempt {attempt + 1} failed: {str(e)}, "
f"{current_delay:.1f}s, retrying..."
)
if on_retry:
@ -131,7 +131,7 @@ def retry_with_backoff_async(
class RetryableAPIClient:
"""
可重试的API客户端封装
Retry-capable API client wrapper
"""
def __init__(
@ -154,16 +154,16 @@ class RetryableAPIClient:
**kwargs
) -> Any:
"""
执行函数调用并在失败时重试
Execute a function with retry on failure
Args:
func: 要调用的函数
*args: 函数参数
exceptions: 需要重试的异常类型
**kwargs: 函数关键字参数
func: function to call
*args: function positional args
exceptions: exception types to retry on
**kwargs: function keyword args
Returns:
函数返回值
function return value
"""
last_exception = None
delay = self.initial_delay
@ -176,15 +176,15 @@ class RetryableAPIClient:
last_exception = e
if attempt == self.max_retries:
logger.error(f"API调用在 {self.max_retries} 次重试后仍失败: {str(e)}")
logger.error(f"API call still failed after {self.max_retries} retries: {str(e)}")
raise
current_delay = min(delay, self.max_delay)
current_delay = current_delay * (0.5 + random.random())
logger.warning(
f"API调用第 {attempt + 1} 次尝试失败: {str(e)}, "
f"{current_delay:.1f}秒后重试..."
f"API callattempt {attempt + 1} failed: {str(e)}, "
f"{current_delay:.1f}s, retrying..."
)
time.sleep(current_delay)
@ -200,16 +200,16 @@ class RetryableAPIClient:
continue_on_failure: bool = True
) -> Tuple[list, list]:
"""
批量调用并对每个失败项单独重试
Batch call with per-item retry on failure
Args:
items: 要处理的项目列表
process_func: 处理函数接收单个item作为参数
exceptions: 需要重试的异常类型
continue_on_failure: 单项失败后是否继续处理其他项
items: list of items to process
process_func: processing function, takes a single item
exceptions: exception types to retry on
continue_on_failure: whether to keep going after an individual item fails
Returns:
(成功结果列表, 失败项列表)
(list of successful results, list of failed items)
"""
results = []
failures = []
@ -224,7 +224,7 @@ class RetryableAPIClient:
results.append(result)
except Exception as e:
logger.error(f"处理第 {idx + 1} 项失败: {str(e)}")
logger.error(f"Processing item {idx + 1} failed: {str(e)}")
failures.append({
"index": idx,
"item": item,

View File

@ -1,143 +1,46 @@
"""Zep Graph 分页读取工具。
"""Graph pagination helpers (replaces zep_paging.py).
Zep node/edge 列表接口使用 UUID cursor 分页
本模块封装自动翻页逻辑含单页重试对调用方透明地返回完整列表
FalkorDB returns nodes/edges in a single query (we cap at 5000 in the adapter),
so there's no real pagination here — but the call sites in graph_builder.py and
zep_entity_reader.py import `fetch_all_nodes` / `fetch_all_edges`, so we keep
those names as adapters over GraphitiAdapter.
"""
from __future__ import annotations
import time
from collections.abc import Callable
from typing import Any
from zep_cloud import InternalServerError
from zep_cloud.client import Zep
import logging
from typing import Any, List
from .logger import get_logger
logger = get_logger('mirofish.zep_paging')
_DEFAULT_PAGE_SIZE = 100
_MAX_NODES = 2000
_DEFAULT_MAX_RETRIES = 3
_DEFAULT_RETRY_DELAY = 2.0 # seconds, doubles each retry
def _fetch_page_with_retry(
api_call: Callable[..., list[Any]],
*args: Any,
max_retries: int = _DEFAULT_MAX_RETRIES,
retry_delay: float = _DEFAULT_RETRY_DELAY,
page_description: str = "page",
**kwargs: Any,
) -> list[Any]:
"""单页请求,失败时指数退避重试。仅重试网络/IO类瞬态错误。"""
if max_retries < 1:
raise ValueError("max_retries must be >= 1")
last_exception: Exception | None = None
delay = retry_delay
for attempt in range(max_retries):
try:
return api_call(*args, **kwargs)
except (ConnectionError, TimeoutError, OSError, InternalServerError) as e:
last_exception = e
if attempt < max_retries - 1:
logger.warning(
f"Zep {page_description} attempt {attempt + 1} failed: {str(e)[:100]}, retrying in {delay:.1f}s..."
)
time.sleep(delay)
delay *= 2
else:
logger.error(f"Zep {page_description} failed after {max_retries} attempts: {str(e)}")
assert last_exception is not None
raise last_exception
def fetch_all_nodes(
client: Zep,
client: Any, # ignored — kept for signature compat with Zep call sites
graph_id: str,
page_size: int = _DEFAULT_PAGE_SIZE,
max_items: int = _MAX_NODES,
max_retries: int = _DEFAULT_MAX_RETRIES,
retry_delay: float = _DEFAULT_RETRY_DELAY,
) -> list[Any]:
"""分页获取图谱节点,最多返回 max_items 条(默认 2000。每页请求自带重试。"""
all_nodes: list[Any] = []
cursor: str | None = None
page_num = 0
while True:
kwargs: dict[str, Any] = {"limit": page_size}
if cursor is not None:
kwargs["uuid_cursor"] = cursor
page_num += 1
batch = _fetch_page_with_retry(
client.graph.node.get_by_graph_id,
graph_id,
max_retries=max_retries,
retry_delay=retry_delay,
page_description=f"fetch nodes page {page_num} (graph={graph_id})",
**kwargs,
)
if not batch:
break
all_nodes.extend(batch)
if len(all_nodes) >= max_items:
all_nodes = all_nodes[:max_items]
logger.warning(f"Node count reached limit ({max_items}), stopping pagination for graph {graph_id}")
break
if len(batch) < page_size:
break
cursor = getattr(batch[-1], "uuid_", None) or getattr(batch[-1], "uuid", None)
if cursor is None:
logger.warning(f"Node missing uuid field, stopping pagination at {len(all_nodes)} nodes")
break
return all_nodes
page_size: int = 100, # ignored
max_items: int = 2000,
max_retries: int = 3, # ignored
retry_delay: float = 2.0, # ignored
) -> List[Any]:
"""Return all nodes in `graph_id` (FalkorDB capped at max_items, default 2000)."""
from ..services.graphiti_service import get_graphiti_adapter
adapter = get_graphiti_adapter()
nodes = adapter.get_all_nodes(graph_id)
if len(nodes) > max_items:
logger.warning(f"Node count {len(nodes)} > max_items {max_items}, truncating")
nodes = nodes[:max_items]
return nodes
def fetch_all_edges(
client: Zep,
client: Any,
graph_id: str,
page_size: int = _DEFAULT_PAGE_SIZE,
max_retries: int = _DEFAULT_MAX_RETRIES,
retry_delay: float = _DEFAULT_RETRY_DELAY,
) -> list[Any]:
"""分页获取图谱所有边,返回完整列表。每页请求自带重试。"""
all_edges: list[Any] = []
cursor: str | None = None
page_num = 0
while True:
kwargs: dict[str, Any] = {"limit": page_size}
if cursor is not None:
kwargs["uuid_cursor"] = cursor
page_num += 1
batch = _fetch_page_with_retry(
client.graph.edge.get_by_graph_id,
graph_id,
max_retries=max_retries,
retry_delay=retry_delay,
page_description=f"fetch edges page {page_num} (graph={graph_id})",
**kwargs,
)
if not batch:
break
all_edges.extend(batch)
if len(batch) < page_size:
break
cursor = getattr(batch[-1], "uuid_", None) or getattr(batch[-1], "uuid", None)
if cursor is None:
logger.warning(f"Edge missing uuid field, stopping pagination at {len(all_edges)} edges")
break
return all_edges
page_size: int = 100,
max_retries: int = 3,
retry_delay: float = 2.0,
) -> List[Any]:
"""Return all edges in `graph_id`."""
from ..services.graphiti_service import get_graphiti_adapter
adapter = get_graphiti_adapter()
return adapter.get_all_edges(graph_id, include_temporal=True)

View File

@ -1,6 +1,6 @@
[project]
name = "mirofish-backend"
version = "0.1.0"
version = "0.2.0"
description = "MiroFish - 简洁通用的群体智能引擎,预测万物"
requires-python = ">=3.11,<3.13"
license = { text = "AGPL-3.0" }
@ -16,11 +16,24 @@ dependencies = [
# LLM 相关
"openai>=1.0.0",
# Zep Cloud
"zep-cloud==3.13.0",
# 知识图谱 (replaces zep-cloud)
# Graphiti is the open-source engine behind Zep; runs server-side extraction
# using your LLM provider (MiniMax M3) and stores the graph in FalkorDB.
"graphiti-core[falkordb]>=0.20.0",
# falkordb is the official Python client for the graph backend
"falkordb>=1.0.0",
# sentence-transformers is the local embedding provider we use (FalkorDB
# doesn't bundle one, and MiniMax's bespoke embeddings API is rate-limited
# and not OpenAI-compatible)
"sentence-transformers>=3.0.0",
"torch>=2.0.0",
# OASIS 社交媒体模拟
"camel-oasis==0.2.5",
# Vendored fork — see vendor/camel-oasis/pyproject.toml for the rationale
# (the upstream package pins neo4j==5.23.0 which conflicts with
# graphiti-core[falkordb]'s neo4j>=5.26.0; our fork relaxes the pin since
# MiroFish's OASIS scripts never invoke the neo4j driver).
"camel-oasis @ file:///app/backend/vendor/camel-oasis",
"camel-ai==0.2.78",
# 文件处理
@ -30,7 +43,10 @@ dependencies = [
"chardet>=5.0.0",
# 工具库
# 环境变量加载
"python-dotenv>=1.0.0",
# 数据验证
"pydantic>=2.0.0",
]
@ -53,3 +69,9 @@ dev = [
[tool.hatch.build.targets.wheel]
packages = ["app"]
# Required because the deps list includes a direct-reference local path dep
# (camel-oasis @ file:///app/backend/vendor/camel-oasis). Without this,
# hatchling refuses to build the editable.
[tool.hatch.metadata]
allow-direct-references = true

View File

@ -13,12 +13,23 @@ flask-cors>=6.0.0
# OpenAI SDK统一使用 OpenAI 格式调用 LLM
openai>=1.0.0
# ============= Zep Cloud =============
zep-cloud==3.13.0
# ============= Knowledge Graph (replaces zep-cloud) =============
# Graphiti is the open-source engine behind Zep; runs server-side extraction
# using your LLM provider (MiniMax M3) and stores the graph in FalkorDB.
graphiti-core[falkordb]>=0.20.0
falkordb>=1.0.0
# sentence-transformers is the local embedding provider we use (FalkorDB
# doesn't bundle one, and MiniMax's bespoke embeddings API is rate-limited
# and not OpenAI-compatible)
sentence-transformers>=3.0.0
torch>=2.0.0
# ============= OASIS 社交媒体模拟 =============
# OASIS 社交模拟框架
camel-oasis==0.2.5
# Vendored fork — see vendor/camel-oasis/pyproject.toml for the rationale
# (the upstream package pins neo4j==5.23.0 which conflicts with
# graphiti-core[falkordb]'s neo4j>=5.26.0; our fork relaxes the pin since
# MiroFish's OASIS scripts never invoke the neo4j driver).
camel-oasis @ file:///app/backend/vendor/camel-oasis
camel-ai==0.2.78
# ============= 文件处理 =============

View File

@ -1,21 +1,21 @@
"""
MiroFish Backend 启动入口
MiroFish backend entry point
"""
import os
import sys
# 解决 Windows 控制台中文乱码问题:在所有导入之前设置 UTF-8 编码
# Work around Windows console garbled Chinese outputSet UTF-8 encoding before any imports
if sys.platform == 'win32':
# 设置环境变量确保 Python 使用 UTF-8
# Set environment variable to ensure Python uses UTF-8
os.environ.setdefault('PYTHONIOENCODING', 'utf-8')
# 重新配置标准输出流为 UTF-8
# Reconfigure stdout to UTF-8
if hasattr(sys.stdout, 'reconfigure'):
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
if hasattr(sys.stderr, 'reconfigure'):
sys.stderr.reconfigure(encoding='utf-8', errors='replace')
# 添加项目根目录到路径
# Add the project root to sys.path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from app import create_app
@ -23,25 +23,25 @@ from app.config import Config
def main():
"""主函数"""
# 验证配置
"""Main entry point"""
# Validate configuration
errors = Config.validate()
if errors:
print("配置错误:")
print("Configuration error:")
for err in errors:
print(f" - {err}")
print("\n请检查 .env 文件中的配置")
print("\nPlease check the configuration in .env")
sys.exit(1)
# 创建应用
# Create app
app = create_app()
# 获取运行配置
# Read runtime config
host = os.environ.get('FLASK_HOST', '0.0.0.0')
port = int(os.environ.get('FLASK_PORT', 5001))
debug = Config.DEBUG
# 启动服务
# Start the server
app.run(host=host, port=port, debug=debug, threaded=True)

View File

@ -1,15 +1,15 @@
"""
动作日志记录器
用于记录OASIS模拟中每个Agent的动作供后端监控使用
Action logger
Records every Agent's actions in the OASIS simulation for backend monitoring.
日志结构:
Log structure:
sim_xxx/
twitter/
actions.jsonl # Twitter 平台动作日志
actions.jsonl # Twitter platform action log
reddit/
actions.jsonl # Reddit 平台动作日志
simulation.log # 主模拟进程日志
run_state.json # 运行状态API 查询用)
actions.jsonl # Reddit platform action log
simulation.log # Main simulation process log
run_state.json # Run state (used for API queries)
"""
import json
@ -20,26 +20,26 @@ from typing import Dict, Any, Optional
class PlatformActionLogger:
"""单平台动作日志记录器"""
"""Single-platform action logger"""
def __init__(self, platform: str, base_dir: str):
"""
初始化日志记录器
Initialize the logger.
Args:
platform: 平台名称 (twitter/reddit)
base_dir: 模拟目录的基础路径
platform: Platform name (twitter/reddit)
base_dir: Base path of the simulation directory
"""
self.platform = platform
self.base_dir = base_dir
self.log_dir = os.path.join(base_dir, platform)
self.log_path = os.path.join(self.log_dir, "actions.jsonl")
self._ensure_dir()
def _ensure_dir(self):
"""确保目录存在"""
"""Ensure the directory exists"""
os.makedirs(self.log_dir, exist_ok=True)
def log_action(
self,
round_num: int,
@ -50,7 +50,7 @@ class PlatformActionLogger:
result: Optional[str] = None,
success: bool = True
):
"""记录一个动作"""
"""Record a single action"""
entry = {
"round": round_num,
"timestamp": datetime.now().isoformat(),
@ -66,31 +66,31 @@ class PlatformActionLogger:
f.write(json.dumps(entry, ensure_ascii=False) + '\n')
def log_round_start(self, round_num: int, simulated_hour: int):
"""记录轮次开始"""
"""Record round start"""
entry = {
"round": round_num,
"timestamp": datetime.now().isoformat(),
"event_type": "round_start",
"simulated_hour": simulated_hour,
}
with open(self.log_path, 'a', encoding='utf-8') as f:
f.write(json.dumps(entry, ensure_ascii=False) + '\n')
def log_round_end(self, round_num: int, actions_count: int):
"""记录轮次结束"""
"""Record round end"""
entry = {
"round": round_num,
"timestamp": datetime.now().isoformat(),
"event_type": "round_end",
"actions_count": actions_count,
}
with open(self.log_path, 'a', encoding='utf-8') as f:
f.write(json.dumps(entry, ensure_ascii=False) + '\n')
def log_simulation_start(self, config: Dict[str, Any]):
"""记录模拟开始"""
"""Record simulation start"""
entry = {
"timestamp": datetime.now().isoformat(),
"event_type": "simulation_start",
@ -98,12 +98,12 @@ class PlatformActionLogger:
"total_rounds": config.get("time_config", {}).get("total_simulation_hours", 72) * 2,
"agents_count": len(config.get("agent_configs", [])),
}
with open(self.log_path, 'a', encoding='utf-8') as f:
f.write(json.dumps(entry, ensure_ascii=False) + '\n')
def log_simulation_end(self, total_rounds: int, total_actions: int):
"""记录模拟结束"""
"""Record simulation end"""
entry = {
"timestamp": datetime.now().isoformat(),
"event_type": "simulation_end",
@ -118,35 +118,35 @@ class PlatformActionLogger:
class SimulationLogManager:
"""
模拟日志管理器
统一管理所有日志文件按平台分离
Simulation log manager
Unifies management of all log files, separated by platform.
"""
def __init__(self, simulation_dir: str):
"""
初始化日志管理器
Initialize the log manager.
Args:
simulation_dir: 模拟目录路径
simulation_dir: Path of the simulation directory
"""
self.simulation_dir = simulation_dir
self.twitter_logger: Optional[PlatformActionLogger] = None
self.reddit_logger: Optional[PlatformActionLogger] = None
self._main_logger: Optional[logging.Logger] = None
# 设置主日志
# Set up the main logger
self._setup_main_logger()
def _setup_main_logger(self):
"""设置主模拟日志"""
"""Set up the main simulation logger"""
log_path = os.path.join(self.simulation_dir, "simulation.log")
# 创建 logger
# Create the logger
self._main_logger = logging.getLogger(f"simulation.{os.path.basename(self.simulation_dir)}")
self._main_logger.setLevel(logging.INFO)
self._main_logger.handlers.clear()
# 文件处理器
# File handler
file_handler = logging.FileHandler(log_path, encoding='utf-8', mode='w')
file_handler.setLevel(logging.INFO)
file_handler.setFormatter(logging.Formatter(
@ -154,8 +154,8 @@ class SimulationLogManager:
datefmt='%Y-%m-%d %H:%M:%S'
))
self._main_logger.addHandler(file_handler)
# 控制台处理器
# Console handler
console_handler = logging.StreamHandler()
console_handler.setLevel(logging.INFO)
console_handler.setFormatter(logging.Formatter(
@ -163,23 +163,23 @@ class SimulationLogManager:
datefmt='%H:%M:%S'
))
self._main_logger.addHandler(console_handler)
self._main_logger.propagate = False
def get_twitter_logger(self) -> PlatformActionLogger:
"""获取 Twitter 平台日志记录器"""
"""Get the Twitter platform logger"""
if self.twitter_logger is None:
self.twitter_logger = PlatformActionLogger("twitter", self.simulation_dir)
return self.twitter_logger
def get_reddit_logger(self) -> PlatformActionLogger:
"""获取 Reddit 平台日志记录器"""
"""Get the Reddit platform logger"""
if self.reddit_logger is None:
self.reddit_logger = PlatformActionLogger("reddit", self.simulation_dir)
return self.reddit_logger
def log(self, message: str, level: str = "info"):
"""记录主日志"""
"""Record into the main log"""
if self._main_logger:
getattr(self._main_logger, level.lower(), self._main_logger.info)(message)
@ -196,12 +196,12 @@ class SimulationLogManager:
self.log(message, "debug")
# ============ 兼容旧接口 ============
# ============ Backward-compat interface ============
class ActionLogger:
"""
动作日志记录器兼容旧接口
建议使用 SimulationLogManager 代替
Action logger (backward-compat interface)
Prefer using SimulationLogManager instead.
"""
def __init__(self, log_path: str):
@ -288,12 +288,12 @@ class ActionLogger:
f.write(json.dumps(entry, ensure_ascii=False) + '\n')
# 全局日志实例(兼容旧接口)
# Global log instance (backward-compat interface)
_global_logger: Optional[ActionLogger] = None
def get_logger(log_path: Optional[str] = None) -> ActionLogger:
"""获取全局日志实例(兼容旧接口)"""
"""Get the global log instance (backward-compat interface)"""
global _global_logger
if log_path:

File diff suppressed because it is too large Load Diff

View File

@ -1,16 +1,16 @@
"""
OASIS Reddit模拟预设脚本
此脚本读取配置文件中的参数来执行模拟实现全程自动化
OASIS Reddit simulation preset script
This script reads parameters from a config file to run simulations, fully automated end-to-end
功能特性:
- 完成模拟后不立即关闭环境进入等待命令模式
- 支持通过IPC接收Interview命令
- 支持单个Agent采访和批量采访
- 支持远程关闭环境命令
Features:
- After completing the simulation, do not close the environment immediately; enter wait-for-commands mode
- Support receiving Interview commands via IPC
- Support single-Agent and batch interviews
- Support remote environment-close commands
使用方式:
Usage:
python run_reddit_simulation.py --config /path/to/simulation_config.json
python run_reddit_simulation.py --config /path/to/simulation_config.json --no-wait # 完成后立即关闭
python run_reddit_simulation.py --config /path/to/simulation_config.json --no-wait # close immediately after completion
"""
import argparse
@ -25,18 +25,18 @@ import sqlite3
from datetime import datetime
from typing import Dict, Any, List, Optional
# 全局变量:用于信号处理
# Global variables: for signal handling
_shutdown_event = None
_cleanup_done = False
# 添加项目路径
# Add project paths
_scripts_dir = os.path.dirname(os.path.abspath(__file__))
_backend_dir = os.path.abspath(os.path.join(_scripts_dir, '..'))
_project_root = os.path.abspath(os.path.join(_backend_dir, '..'))
sys.path.insert(0, _scripts_dir)
sys.path.insert(0, _backend_dir)
# 加载项目根目录的 .env 文件(包含 LLM_API_KEY 等配置)
# Load project-root .env file (contains LLM_API_KEY etc.)
from dotenv import load_dotenv
_env_file = os.path.join(_project_root, '.env')
if os.path.exists(_env_file):
@ -51,7 +51,7 @@ import re
class UnicodeFormatter(logging.Formatter):
"""自定义格式化器,将 Unicode 转义序列转换为可读字符"""
"""Custom formatter that converts Unicode escape sequences into readable characters"""
UNICODE_ESCAPE_PATTERN = re.compile(r'\\u([0-9a-fA-F]{4})')
@ -68,24 +68,24 @@ class UnicodeFormatter(logging.Formatter):
class MaxTokensWarningFilter(logging.Filter):
"""过滤掉 camel-ai 关于 max_tokens 的警告(我们故意不设置 max_tokens让模型自行决定"""
"""Filter out camel-ai warnings about max_tokens (we intentionally do not set max_tokens, letting the model decide)"""
def filter(self, record):
# 过滤掉包含 max_tokens 警告的日志
# Filter out log records containing the max_tokens warning
if "max_tokens" in record.getMessage() and "Invalid or missing" in record.getMessage():
return False
return True
# 在模块加载时立即添加过滤器,确保在 camel 代码执行前生效
# Add the filter at module load time, ensuring it takes effect before camel code runs
logging.getLogger().addFilter(MaxTokensWarningFilter())
def setup_oasis_logging(log_dir: str):
"""配置 OASIS 的日志,使用固定名称的日志文件"""
"""Configure OASIS logging using fixed-name log files"""
os.makedirs(log_dir, exist_ok=True)
# 清理旧的日志文件
# Clean up old log files
for f in os.listdir(log_dir):
old_log = os.path.join(log_dir, f)
if os.path.isfile(old_log) and f.endswith('.log'):
@ -126,25 +126,25 @@ try:
generate_reddit_agent_graph
)
except ImportError as e:
print(f"错误: 缺少依赖 {e}")
print("请先安装: pip install oasis-ai camel-ai")
print(f"Error: missing dependency {e}")
print("Please install first: pip install oasis-ai camel-ai")
sys.exit(1)
# IPC相关常量
# IPC-related constants
IPC_COMMANDS_DIR = "ipc_commands"
IPC_RESPONSES_DIR = "ipc_responses"
ENV_STATUS_FILE = "env_status.json"
class CommandType:
"""命令类型常量"""
"""Command type constants"""
INTERVIEW = "interview"
BATCH_INTERVIEW = "batch_interview"
CLOSE_ENV = "close_env"
class IPCHandler:
"""IPC命令处理器"""
"""IPC command handler"""
def __init__(self, simulation_dir: str, env, agent_graph):
self.simulation_dir = simulation_dir
@ -155,12 +155,12 @@ class IPCHandler:
self.status_file = os.path.join(simulation_dir, ENV_STATUS_FILE)
self._running = True
# 确保目录存在
# Ensure the directory exists
os.makedirs(self.commands_dir, exist_ok=True)
os.makedirs(self.responses_dir, exist_ok=True)
def update_status(self, status: str):
"""更新环境状态"""
"""Update environment status"""
with open(self.status_file, 'w', encoding='utf-8') as f:
json.dump({
"status": status,
@ -168,11 +168,11 @@ class IPCHandler:
}, f, ensure_ascii=False, indent=2)
def poll_command(self) -> Optional[Dict[str, Any]]:
"""轮询获取待处理命令"""
"""Poll for pending commands"""
if not os.path.exists(self.commands_dir):
return None
# 获取命令文件(按时间排序)
# Get command files (sorted by time)
command_files = []
for filename in os.listdir(self.commands_dir):
if filename.endswith('.json'):
@ -191,7 +191,7 @@ class IPCHandler:
return None
def send_response(self, command_id: str, status: str, result: Dict = None, error: str = None):
"""发送响应"""
"""Send response"""
response = {
"command_id": command_id,
"status": status,
@ -204,7 +204,7 @@ class IPCHandler:
with open(response_file, 'w', encoding='utf-8') as f:
json.dump(response, f, ensure_ascii=False, indent=2)
# 删除命令文件
# Delete command file
command_file = os.path.join(self.commands_dir, f"{command_id}.json")
try:
os.remove(command_file)
@ -213,49 +213,49 @@ class IPCHandler:
async def handle_interview(self, command_id: str, agent_id: int, prompt: str) -> bool:
"""
处理单个Agent采访命令
Handle single-Agent interview command
Returns:
True 表示成功False 表示失败
True on success, False on failure
"""
try:
# 获取Agent
# Get Agent
agent = self.agent_graph.get_agent(agent_id)
# 创建Interview动作
# Create Interview action
interview_action = ManualAction(
action_type=ActionType.INTERVIEW,
action_args={"prompt": prompt}
)
# 执行Interview
# Execute Interview
actions = {agent: interview_action}
await self.env.step(actions)
# 从数据库获取结果
# Get result from database
result = self._get_interview_result(agent_id)
self.send_response(command_id, "completed", result=result)
print(f" Interview完成: agent_id={agent_id}")
print(f" Interview completed: agent_id={agent_id}")
return True
except Exception as e:
error_msg = str(e)
print(f" Interview失败: agent_id={agent_id}, error={error_msg}")
print(f" Interview failed: agent_id={agent_id}, error={error_msg}")
self.send_response(command_id, "failed", error=error_msg)
return False
async def handle_batch_interview(self, command_id: str, interviews: List[Dict]) -> bool:
"""
处理批量采访命令
Handle batch interview command
Args:
interviews: [{"agent_id": int, "prompt": str}, ...]
"""
try:
# 构建动作字典
# Build action dict
actions = {}
agent_prompts = {} # 记录每个agent的prompt
agent_prompts = {} # Record each agent's prompt
for interview in interviews:
agent_id = interview.get("agent_id")
@ -269,16 +269,16 @@ class IPCHandler:
)
agent_prompts[agent_id] = prompt
except Exception as e:
print(f" 警告: 无法获取Agent {agent_id}: {e}")
print(f" Warning: unable to fetch Agent {agent_id}: {e}")
if not actions:
self.send_response(command_id, "failed", error="没有有效的Agent")
self.send_response(command_id, "failed", error="No valid agents")
return False
# 执行批量Interview
# Execute batch Interview
await self.env.step(actions)
# 获取所有结果
# Get all results
results = {}
for agent_id in agent_prompts.keys():
result = self._get_interview_result(agent_id)
@ -288,17 +288,17 @@ class IPCHandler:
"interviews_count": len(results),
"results": results
})
print(f" 批量Interview完成: {len(results)} 个Agent")
print(f" Batch Interview completed: {len(results)} agents")
return True
except Exception as e:
error_msg = str(e)
print(f" 批量Interview失败: {error_msg}")
print(f" Batch Interview failed: {error_msg}")
self.send_response(command_id, "failed", error=error_msg)
return False
def _get_interview_result(self, agent_id: int) -> Dict[str, Any]:
"""从数据库获取最新的Interview结果"""
"""Get the latest Interview results from the database"""
db_path = os.path.join(self.simulation_dir, "reddit_simulation.db")
result = {
@ -314,7 +314,7 @@ class IPCHandler:
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
# 查询最新的Interview记录
# Query the latest Interview record
cursor.execute("""
SELECT user_id, info, created_at
FROM trace
@ -336,16 +336,16 @@ class IPCHandler:
conn.close()
except Exception as e:
print(f" 读取Interview结果失败: {e}")
print(f" Failed to read Interview results: {e}")
return result
async def process_commands(self) -> bool:
"""
处理所有待处理命令
Handle all pending commands
Returns:
True 表示继续运行False 表示应该退出
True to continue running, False to exit
"""
command = self.poll_command()
if not command:
@ -355,7 +355,7 @@ class IPCHandler:
command_type = command.get("command_type")
args = command.get("args", {})
print(f"\n收到IPC命令: {command_type}, id={command_id}")
print(f"\nReceived IPC command: {command_type}, id={command_id}")
if command_type == CommandType.INTERVIEW:
await self.handle_interview(
@ -373,19 +373,19 @@ class IPCHandler:
return True
elif command_type == CommandType.CLOSE_ENV:
print("收到关闭环境命令")
self.send_response(command_id, "completed", result={"message": "环境即将关闭"})
print("Received close-environment command")
self.send_response(command_id, "completed", result={"message": "Environment is about to close"})
return False
else:
self.send_response(command_id, "failed", error=f"未知命令类型: {command_type}")
self.send_response(command_id, "failed", error=f"Unknown command type: {command_type}")
return True
class RedditSimulationRunner:
"""Reddit模拟运行器"""
"""Reddit simulation runner"""
# Reddit可用动作不包含INTERVIEWINTERVIEW只能通过ManualAction手动触发
# Reddit available actions (does not include INTERVIEW; INTERVIEW can only be triggered manually via ManualAction)
AVAILABLE_ACTIONS = [
ActionType.LIKE_POST,
ActionType.DISLIKE_POST,
@ -404,11 +404,11 @@ class RedditSimulationRunner:
def __init__(self, config_path: str, wait_for_commands: bool = True):
"""
初始化模拟运行器
Initialize the simulation runner
Args:
config_path: 配置文件路径 (simulation_config.json)
wait_for_commands: 模拟完成后是否等待命令默认True
config_path: Configuration file path (simulation_config.json)
wait_for_commands: Whether to wait for commands after the simulation completes (default True)
"""
self.config_path = config_path
self.config = self._load_config()
@ -419,47 +419,47 @@ class RedditSimulationRunner:
self.ipc_handler = None
def _load_config(self) -> Dict[str, Any]:
"""加载配置文件"""
"""Load configuration file"""
with open(self.config_path, 'r', encoding='utf-8') as f:
return json.load(f)
def _get_profile_path(self) -> str:
"""获取Profile文件路径"""
"""Get Profile file path"""
return os.path.join(self.simulation_dir, "reddit_profiles.json")
def _get_db_path(self) -> str:
"""获取数据库路径"""
"""Get database path"""
return os.path.join(self.simulation_dir, "reddit_simulation.db")
def _create_model(self):
"""
创建LLM模型
Create the LLM model
统一使用项目根目录 .env 文件中的配置优先级最高
- LLM_API_KEY: API密钥
- LLM_BASE_URL: API基础URL
- LLM_MODEL_NAME: 模型名称
Use config from project-root .env file (highest priority):
- LLM_API_KEY: API key
- LLM_BASE_URL: API base URL
- LLM_MODEL_NAME: Model name
"""
# 优先从 .env 读取配置
# Prefer reading config from .env
llm_api_key = os.environ.get("LLM_API_KEY", "")
llm_base_url = os.environ.get("LLM_BASE_URL", "")
llm_model = os.environ.get("LLM_MODEL_NAME", "")
# 如果 .env 中没有,则使用 config 作为备用
# If .env has no value, fall back to config
if not llm_model:
llm_model = self.config.get("llm_model", "gpt-4o-mini")
# 设置 camel-ai 所需的环境变量
# Set environment variables required by camel-ai
if llm_api_key:
os.environ["OPENAI_API_KEY"] = llm_api_key
if not os.environ.get("OPENAI_API_KEY"):
raise ValueError("缺少 API Key 配置,请在项目根目录 .env 文件中设置 LLM_API_KEY")
raise ValueError("Missing API Key config, please set LLM_API_KEY in the project root .env file")
if llm_base_url:
os.environ["OPENAI_API_BASE_URL"] = llm_base_url
print(f"LLM配置: model={llm_model}, base_url={llm_base_url[:40] if llm_base_url else '默认'}...")
print(f"LLM config: model={llm_model}, base_url={llm_base_url[:40] if llm_base_url else 'default'}...")
return ModelFactory.create(
model_platform=ModelPlatformType.OPENAI,
@ -473,7 +473,7 @@ class RedditSimulationRunner:
round_num: int
) -> List:
"""
根据时间和配置决定本轮激活哪些Agent
Determine which Agents to activate this round based on time and config
"""
time_config = self.config.get("time_config", {})
agent_configs = self.config.get("agent_configs", [])
@ -521,16 +521,16 @@ class RedditSimulationRunner:
return active_agents
async def run(self, max_rounds: int = None):
"""运行Reddit模拟
"""Run Reddit simulation
Args:
max_rounds: 最大模拟轮数可选用于截断过长的模拟
max_rounds: Maximum simulation rounds (optional, used to truncate overlong simulations)
"""
print("=" * 60)
print("OASIS Reddit模拟")
print(f"配置文件: {self.config_path}")
print(f"模拟ID: {self.config.get('simulation_id', 'unknown')}")
print(f"等待命令模式: {'启用' if self.wait_for_commands else '禁用'}")
print("OASIS Reddit Simulation")
print(f"Config file: {self.config_path}")
print(f"Simulation ID: {self.config.get('simulation_id', 'unknown')}")
print(f"Wait-for-commands mode: {'enabled' if self.wait_for_commands else 'disabled'}")
print("=" * 60)
time_config = self.config.get("time_config", {})
@ -538,28 +538,28 @@ class RedditSimulationRunner:
minutes_per_round = time_config.get("minutes_per_round", 30)
total_rounds = (total_hours * 60) // minutes_per_round
# 如果指定了最大轮数,则截断
# If a max-rounds is specified, truncate
if max_rounds is not None and max_rounds > 0:
original_rounds = total_rounds
total_rounds = min(total_rounds, max_rounds)
if total_rounds < original_rounds:
print(f"\n轮数已截断: {original_rounds} -> {total_rounds} (max_rounds={max_rounds})")
print(f"\nRounds truncated: {original_rounds} -> {total_rounds} (max_rounds={max_rounds})")
print(f"\n模拟参数:")
print(f" - 总模拟时长: {total_hours}小时")
print(f" - 每轮时间: {minutes_per_round}分钟")
print(f" - 总轮数: {total_rounds}")
print(f"\nSimulation parameters:")
print(f" - Total simulation duration: {total_hours} hours")
print(f" - Time per round: {minutes_per_round} minutes")
print(f" - Total rounds: {total_rounds}")
if max_rounds:
print(f" - 最大轮数限制: {max_rounds}")
print(f" - Agent数量: {len(self.config.get('agent_configs', []))}")
print(f" - Max rounds limit: {max_rounds}")
print(f" - Number of Agents: {len(self.config.get('agent_configs', []))}")
print("\n初始化LLM模型...")
print("\nInitializing LLM model...")
model = self._create_model()
print("加载Agent Profile...")
print("Loading Agent Profile...")
profile_path = self._get_profile_path()
if not os.path.exists(profile_path):
print(f"错误: Profile文件不存在: {profile_path}")
print(f"Error: Profile file does not exist: {profile_path}")
return
self.agent_graph = await generate_reddit_agent_graph(
@ -571,29 +571,29 @@ class RedditSimulationRunner:
db_path = self._get_db_path()
if os.path.exists(db_path):
os.remove(db_path)
print(f"已删除旧数据库: {db_path}")
print(f"Deleted old database: {db_path}")
print("创建OASIS环境...")
print("Creating OASIS environment...")
self.env = oasis.make(
agent_graph=self.agent_graph,
platform=oasis.DefaultPlatformType.REDDIT,
database_path=db_path,
semaphore=30, # 限制最大并发 LLM 请求数,防止 API 过载
semaphore=30, # Limit max concurrent LLM requests to prevent API overload
)
await self.env.reset()
print("环境初始化完成\n")
print("Environment initialization complete\n")
# 初始化IPC处理器
# Initialize IPC handler
self.ipc_handler = IPCHandler(self.simulation_dir, self.env, self.agent_graph)
self.ipc_handler.update_status("running")
# 执行初始事件
# Execute initial events
event_config = self.config.get("event_config", {})
initial_posts = event_config.get("initial_posts", [])
if initial_posts:
print(f"执行初始事件 ({len(initial_posts)}条初始帖子)...")
print(f"Executing initial events ({len(initial_posts)} initial posts)...")
initial_actions = {}
for post in initial_posts:
agent_id = post.get("poster_agent_id", 0)
@ -613,14 +613,14 @@ class RedditSimulationRunner:
action_args={"content": content}
)
except Exception as e:
print(f" 警告: 无法为Agent {agent_id}创建初始帖子: {e}")
print(f" Warning: unable to create initial post for Agent {agent_id}: {e}")
if initial_actions:
await self.env.step(initial_actions)
print(f" 已发布 {len(initial_actions)} 条初始帖子")
print(f" Published {len(initial_actions)} initial posts")
# 主模拟循环
print("\n开始模拟循环...")
# Main simulation loop
print("\nStarting simulation loop...")
start_time = datetime.now()
for round_num in range(total_rounds):
@ -651,20 +651,20 @@ class RedditSimulationRunner:
f"- elapsed: {elapsed:.1f}s")
total_elapsed = (datetime.now() - start_time).total_seconds()
print(f"\n模拟循环完成!")
print(f" - 总耗时: {total_elapsed:.1f}")
print(f" - 数据库: {db_path}")
print(f"\nSimulation loop completed!")
print(f" - Total elapsed: {total_elapsed:.1f} seconds")
print(f" - Database: {db_path}")
# 是否进入等待命令模式
# Whether to enter wait-for-commands mode
if self.wait_for_commands:
print("\n" + "=" * 60)
print("进入等待命令模式 - 环境保持运行")
print("支持的命令: interview, batch_interview, close_env")
print("Entering wait-for-commands mode - environment keeps running")
print("Supported commands: interview, batch_interview, close_env")
print("=" * 60)
self.ipc_handler.update_status("alive")
# 等待命令循环(使用全局 _shutdown_event
# Wait-for-commands loop (using global _shutdown_event)
try:
while not _shutdown_event.is_set():
should_continue = await self.ipc_handler.process_commands()
@ -672,58 +672,58 @@ class RedditSimulationRunner:
break
try:
await asyncio.wait_for(_shutdown_event.wait(), timeout=0.5)
break # 收到退出信号
break # Received exit signal
except asyncio.TimeoutError:
pass
except KeyboardInterrupt:
print("\n收到中断信号")
print("\nReceived interrupt signal")
except asyncio.CancelledError:
print("\n任务被取消")
print("\nTask cancelled")
except Exception as e:
print(f"\n命令处理出错: {e}")
print(f"\nCommand processing error: {e}")
print("\n关闭环境...")
print("\nClosing environment...")
# 关闭环境
# Close the environment
self.ipc_handler.update_status("stopped")
await self.env.close()
print("环境已关闭")
print("Environment closed")
print("=" * 60)
async def main():
parser = argparse.ArgumentParser(description='OASIS Reddit模拟')
parser = argparse.ArgumentParser(description='OASIS Reddit Simulation')
parser.add_argument(
'--config',
type=str,
required=True,
help='配置文件路径 (simulation_config.json)'
help='Configuration file path (simulation_config.json)'
)
parser.add_argument(
'--max-rounds',
type=int,
default=None,
help='最大模拟轮数(可选,用于截断过长的模拟)'
help='Maximum simulation rounds (optional, used to truncate overlong simulations)'
)
parser.add_argument(
'--no-wait',
action='store_true',
default=False,
help='模拟完成后立即关闭环境,不进入等待命令模式'
help='Close environment immediately after simulation completes, do not enter wait-for-commands mode'
)
args = parser.parse_args()
# 在 main 函数开始时创建 shutdown 事件
# Create shutdown event at the start of main()
global _shutdown_event
_shutdown_event = asyncio.Event()
if not os.path.exists(args.config):
print(f"错误: 配置文件不存在: {args.config}")
print(f"Error: configuration file does not exist: {args.config}")
sys.exit(1)
# 初始化日志配置(使用固定文件名,清理旧日志)
# Initialize log config (use fixed log file names, clean up old logs)
simulation_dir = os.path.dirname(args.config) or "."
setup_oasis_logging(os.path.join(simulation_dir, "log"))
@ -736,20 +736,20 @@ async def main():
def setup_signal_handlers():
"""
设置信号处理器确保收到 SIGTERM/SIGINT 时能够正确退出
让程序有机会正常清理资源关闭数据库环境等
Set up signal handlers to ensure clean exit on SIGTERM/SIGINT
Give the program a chance to clean up resources (close DB, env, etc.)
"""
def signal_handler(signum, frame):
global _cleanup_done
sig_name = "SIGTERM" if signum == signal.SIGTERM else "SIGINT"
print(f"\n收到 {sig_name} 信号,正在退出...")
print(f"\nReceived {sig_name} signal, exiting...")
if not _cleanup_done:
_cleanup_done = True
if _shutdown_event:
_shutdown_event.set()
else:
# 重复收到信号才强制退出
print("强制退出...")
# Only force exit if the signal is received again
print("Forced exit...")
sys.exit(1)
signal.signal(signal.SIGTERM, signal_handler)
@ -761,9 +761,9 @@ if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
print("\n程序被中断")
print("\nProgram interrupted")
except SystemExit:
pass
finally:
print("模拟进程已退出")
print("Simulation process exited")

View File

@ -1,16 +1,16 @@
"""
OASIS Twitter模拟预设脚本
此脚本读取配置文件中的参数来执行模拟实现全程自动化
OASIS Twitter simulation preset script
This script reads parameters from a config file to run simulations, fully automated end-to-end
功能特性:
- 完成模拟后不立即关闭环境进入等待命令模式
- 支持通过IPC接收Interview命令
- 支持单个Agent采访和批量采访
- 支持远程关闭环境命令
Features:
- After completing the simulation, do not close the environment immediately; enter wait-for-commands mode
- Support receiving Interview commands via IPC
- Support single-Agent and batch interviews
- Support remote environment-close commands
使用方式:
Usage:
python run_twitter_simulation.py --config /path/to/simulation_config.json
python run_twitter_simulation.py --config /path/to/simulation_config.json --no-wait # 完成后立即关闭
python run_twitter_simulation.py --config /path/to/simulation_config.json --no-wait # close immediately after completion
"""
import argparse
@ -25,18 +25,18 @@ import sqlite3
from datetime import datetime
from typing import Dict, Any, List, Optional
# 全局变量:用于信号处理
# Global variables: for signal handling
_shutdown_event = None
_cleanup_done = False
# 添加项目路径
# Add project paths
_scripts_dir = os.path.dirname(os.path.abspath(__file__))
_backend_dir = os.path.abspath(os.path.join(_scripts_dir, '..'))
_project_root = os.path.abspath(os.path.join(_backend_dir, '..'))
sys.path.insert(0, _scripts_dir)
sys.path.insert(0, _backend_dir)
# 加载项目根目录的 .env 文件(包含 LLM_API_KEY 等配置)
# Load project-root .env file (contains LLM_API_KEY etc.)
from dotenv import load_dotenv
_env_file = os.path.join(_project_root, '.env')
if os.path.exists(_env_file):
@ -51,7 +51,7 @@ import re
class UnicodeFormatter(logging.Formatter):
"""自定义格式化器,将 Unicode 转义序列转换为可读字符"""
"""Custom formatter that converts Unicode escape sequences into readable characters"""
UNICODE_ESCAPE_PATTERN = re.compile(r'\\u([0-9a-fA-F]{4})')
@ -68,24 +68,24 @@ class UnicodeFormatter(logging.Formatter):
class MaxTokensWarningFilter(logging.Filter):
"""过滤掉 camel-ai 关于 max_tokens 的警告(我们故意不设置 max_tokens让模型自行决定"""
"""Filter out camel-ai warnings about max_tokens (we intentionally do not set max_tokens, letting the model decide)"""
def filter(self, record):
# 过滤掉包含 max_tokens 警告的日志
# Filter out log records containing the max_tokens warning
if "max_tokens" in record.getMessage() and "Invalid or missing" in record.getMessage():
return False
return True
# 在模块加载时立即添加过滤器,确保在 camel 代码执行前生效
# Add the filter at module load time, ensuring it takes effect before camel code runs
logging.getLogger().addFilter(MaxTokensWarningFilter())
def setup_oasis_logging(log_dir: str):
"""配置 OASIS 的日志,使用固定名称的日志文件"""
"""Configure OASIS logging using fixed-name log files"""
os.makedirs(log_dir, exist_ok=True)
# 清理旧的日志文件
# Clean up old log files
for f in os.listdir(log_dir):
old_log = os.path.join(log_dir, f)
if os.path.isfile(old_log) and f.endswith('.log'):
@ -126,25 +126,25 @@ try:
generate_twitter_agent_graph
)
except ImportError as e:
print(f"错误: 缺少依赖 {e}")
print("请先安装: pip install oasis-ai camel-ai")
print(f"Error: missing dependency {e}")
print("Please install first: pip install oasis-ai camel-ai")
sys.exit(1)
# IPC相关常量
# IPC-related constants
IPC_COMMANDS_DIR = "ipc_commands"
IPC_RESPONSES_DIR = "ipc_responses"
ENV_STATUS_FILE = "env_status.json"
class CommandType:
"""命令类型常量"""
"""Command type constants"""
INTERVIEW = "interview"
BATCH_INTERVIEW = "batch_interview"
CLOSE_ENV = "close_env"
class IPCHandler:
"""IPC命令处理器"""
"""IPC command handler"""
def __init__(self, simulation_dir: str, env, agent_graph):
self.simulation_dir = simulation_dir
@ -155,12 +155,12 @@ class IPCHandler:
self.status_file = os.path.join(simulation_dir, ENV_STATUS_FILE)
self._running = True
# 确保目录存在
# Ensure the directory exists
os.makedirs(self.commands_dir, exist_ok=True)
os.makedirs(self.responses_dir, exist_ok=True)
def update_status(self, status: str):
"""更新环境状态"""
"""Update environment status"""
with open(self.status_file, 'w', encoding='utf-8') as f:
json.dump({
"status": status,
@ -168,11 +168,11 @@ class IPCHandler:
}, f, ensure_ascii=False, indent=2)
def poll_command(self) -> Optional[Dict[str, Any]]:
"""轮询获取待处理命令"""
"""Poll for pending commands"""
if not os.path.exists(self.commands_dir):
return None
# 获取命令文件(按时间排序)
# Get command files (sorted by time)
command_files = []
for filename in os.listdir(self.commands_dir):
if filename.endswith('.json'):
@ -191,7 +191,7 @@ class IPCHandler:
return None
def send_response(self, command_id: str, status: str, result: Dict = None, error: str = None):
"""发送响应"""
"""Send response"""
response = {
"command_id": command_id,
"status": status,
@ -204,7 +204,7 @@ class IPCHandler:
with open(response_file, 'w', encoding='utf-8') as f:
json.dump(response, f, ensure_ascii=False, indent=2)
# 删除命令文件
# Delete command file
command_file = os.path.join(self.commands_dir, f"{command_id}.json")
try:
os.remove(command_file)
@ -213,49 +213,49 @@ class IPCHandler:
async def handle_interview(self, command_id: str, agent_id: int, prompt: str) -> bool:
"""
处理单个Agent采访命令
Handle single-Agent interview command
Returns:
True 表示成功False 表示失败
True on success, False on failure
"""
try:
# 获取Agent
# Get Agent
agent = self.agent_graph.get_agent(agent_id)
# 创建Interview动作
# Create Interview action
interview_action = ManualAction(
action_type=ActionType.INTERVIEW,
action_args={"prompt": prompt}
)
# 执行Interview
# Execute Interview
actions = {agent: interview_action}
await self.env.step(actions)
# 从数据库获取结果
# Get result from database
result = self._get_interview_result(agent_id)
self.send_response(command_id, "completed", result=result)
print(f" Interview完成: agent_id={agent_id}")
print(f" Interview completed: agent_id={agent_id}")
return True
except Exception as e:
error_msg = str(e)
print(f" Interview失败: agent_id={agent_id}, error={error_msg}")
print(f" Interview failed: agent_id={agent_id}, error={error_msg}")
self.send_response(command_id, "failed", error=error_msg)
return False
async def handle_batch_interview(self, command_id: str, interviews: List[Dict]) -> bool:
"""
处理批量采访命令
Handle batch interview command
Args:
interviews: [{"agent_id": int, "prompt": str}, ...]
"""
try:
# 构建动作字典
# Build action dict
actions = {}
agent_prompts = {} # 记录每个agent的prompt
agent_prompts = {} # Record each agent's prompt
for interview in interviews:
agent_id = interview.get("agent_id")
@ -269,16 +269,16 @@ class IPCHandler:
)
agent_prompts[agent_id] = prompt
except Exception as e:
print(f" 警告: 无法获取Agent {agent_id}: {e}")
print(f" Warning: unable to fetch Agent {agent_id}: {e}")
if not actions:
self.send_response(command_id, "failed", error="没有有效的Agent")
self.send_response(command_id, "failed", error="No valid agents")
return False
# 执行批量Interview
# Execute batch Interview
await self.env.step(actions)
# 获取所有结果
# Get all results
results = {}
for agent_id in agent_prompts.keys():
result = self._get_interview_result(agent_id)
@ -288,17 +288,17 @@ class IPCHandler:
"interviews_count": len(results),
"results": results
})
print(f" 批量Interview完成: {len(results)} 个Agent")
print(f" Batch Interview completed: {len(results)} agents")
return True
except Exception as e:
error_msg = str(e)
print(f" 批量Interview失败: {error_msg}")
print(f" Batch Interview failed: {error_msg}")
self.send_response(command_id, "failed", error=error_msg)
return False
def _get_interview_result(self, agent_id: int) -> Dict[str, Any]:
"""从数据库获取最新的Interview结果"""
"""Get the latest Interview results from the database"""
db_path = os.path.join(self.simulation_dir, "twitter_simulation.db")
result = {
@ -314,7 +314,7 @@ class IPCHandler:
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
# 查询最新的Interview记录
# Query the latest Interview record
cursor.execute("""
SELECT user_id, info, created_at
FROM trace
@ -336,16 +336,16 @@ class IPCHandler:
conn.close()
except Exception as e:
print(f" 读取Interview结果失败: {e}")
print(f" Failed to read Interview results: {e}")
return result
async def process_commands(self) -> bool:
"""
处理所有待处理命令
Handle all pending commands
Returns:
True 表示继续运行False 表示应该退出
True to continue running, False to exit
"""
command = self.poll_command()
if not command:
@ -355,7 +355,7 @@ class IPCHandler:
command_type = command.get("command_type")
args = command.get("args", {})
print(f"\n收到IPC命令: {command_type}, id={command_id}")
print(f"\nReceived IPC command: {command_type}, id={command_id}")
if command_type == CommandType.INTERVIEW:
await self.handle_interview(
@ -373,19 +373,19 @@ class IPCHandler:
return True
elif command_type == CommandType.CLOSE_ENV:
print("收到关闭环境命令")
self.send_response(command_id, "completed", result={"message": "环境即将关闭"})
print("Received close-environment command")
self.send_response(command_id, "completed", result={"message": "Environment is about to close"})
return False
else:
self.send_response(command_id, "failed", error=f"未知命令类型: {command_type}")
self.send_response(command_id, "failed", error=f"Unknown command type: {command_type}")
return True
class TwitterSimulationRunner:
"""Twitter模拟运行器"""
"""Twitter simulation runner"""
# Twitter可用动作不包含INTERVIEWINTERVIEW只能通过ManualAction手动触发
# Twitter available actions (does not include INTERVIEW; INTERVIEW can only be triggered manually via ManualAction)
AVAILABLE_ACTIONS = [
ActionType.CREATE_POST,
ActionType.LIKE_POST,
@ -397,11 +397,11 @@ class TwitterSimulationRunner:
def __init__(self, config_path: str, wait_for_commands: bool = True):
"""
初始化模拟运行器
Initialize the simulation runner
Args:
config_path: 配置文件路径 (simulation_config.json)
wait_for_commands: 模拟完成后是否等待命令默认True
config_path: Configuration file path (simulation_config.json)
wait_for_commands: Whether to wait for commands after the simulation completes (default True)
"""
self.config_path = config_path
self.config = self._load_config()
@ -412,47 +412,47 @@ class TwitterSimulationRunner:
self.ipc_handler = None
def _load_config(self) -> Dict[str, Any]:
"""加载配置文件"""
"""Load configuration file"""
with open(self.config_path, 'r', encoding='utf-8') as f:
return json.load(f)
def _get_profile_path(self) -> str:
"""获取Profile文件路径OASIS Twitter使用CSV格式"""
"""Get Profile file path (OASIS Twitter uses CSV format)"""
return os.path.join(self.simulation_dir, "twitter_profiles.csv")
def _get_db_path(self) -> str:
"""获取数据库路径"""
"""Get database path"""
return os.path.join(self.simulation_dir, "twitter_simulation.db")
def _create_model(self):
"""
创建LLM模型
Create the LLM model
统一使用项目根目录 .env 文件中的配置优先级最高
- LLM_API_KEY: API密钥
- LLM_BASE_URL: API基础URL
- LLM_MODEL_NAME: 模型名称
Use config from project-root .env file (highest priority):
- LLM_API_KEY: API key
- LLM_BASE_URL: API base URL
- LLM_MODEL_NAME: Model name
"""
# 优先从 .env 读取配置
# Prefer reading config from .env
llm_api_key = os.environ.get("LLM_API_KEY", "")
llm_base_url = os.environ.get("LLM_BASE_URL", "")
llm_model = os.environ.get("LLM_MODEL_NAME", "")
# 如果 .env 中没有,则使用 config 作为备用
# If .env has no value, fall back to config
if not llm_model:
llm_model = self.config.get("llm_model", "gpt-4o-mini")
# 设置 camel-ai 所需的环境变量
# Set environment variables required by camel-ai
if llm_api_key:
os.environ["OPENAI_API_KEY"] = llm_api_key
if not os.environ.get("OPENAI_API_KEY"):
raise ValueError("缺少 API Key 配置,请在项目根目录 .env 文件中设置 LLM_API_KEY")
raise ValueError("Missing API Key config, please set LLM_API_KEY in the project root .env file")
if llm_base_url:
os.environ["OPENAI_API_BASE_URL"] = llm_base_url
print(f"LLM配置: model={llm_model}, base_url={llm_base_url[:40] if llm_base_url else '默认'}...")
print(f"LLM config: model={llm_model}, base_url={llm_base_url[:40] if llm_base_url else 'default'}...")
return ModelFactory.create(
model_platform=ModelPlatformType.OPENAI,
@ -466,24 +466,24 @@ class TwitterSimulationRunner:
round_num: int
) -> List:
"""
根据时间和配置决定本轮激活哪些Agent
Determine which Agents to activate this round based on time and config
Args:
env: OASIS环境
current_hour: 当前模拟小时0-23
round_num: 当前轮数
env: OASIS environment
current_hour: Current simulation hour (0-23)
round_num: Current round number
Returns:
激活的Agent列表
List of activated Agents
"""
time_config = self.config.get("time_config", {})
agent_configs = self.config.get("agent_configs", [])
# 基础激活数量
# Base activation count
base_min = time_config.get("agents_per_hour_min", 5)
base_max = time_config.get("agents_per_hour_max", 20)
# 根据时段调整
# Adjust by time period
peak_hours = time_config.get("peak_hours", [9, 10, 11, 14, 15, 20, 21, 22])
off_peak_hours = time_config.get("off_peak_hours", [0, 1, 2, 3, 4, 5])
@ -496,28 +496,28 @@ class TwitterSimulationRunner:
target_count = int(random.uniform(base_min, base_max) * multiplier)
# 根据每个Agent的配置计算激活概率
# Compute activation probability from each Agent's config
candidates = []
for cfg in agent_configs:
agent_id = cfg.get("agent_id", 0)
active_hours = cfg.get("active_hours", list(range(8, 23)))
activity_level = cfg.get("activity_level", 0.5)
# 检查是否在活跃时间
# Check if within active hours
if current_hour not in active_hours:
continue
# 根据活跃度计算概率
# Compute probability from activity level
if random.random() < activity_level:
candidates.append(agent_id)
# 随机选择
# Random selection
selected_ids = random.sample(
candidates,
min(target_count, len(candidates))
) if candidates else []
# 转换为Agent对象
# Convert to Agent objects
active_agents = []
for agent_id in selected_ids:
try:
@ -529,50 +529,50 @@ class TwitterSimulationRunner:
return active_agents
async def run(self, max_rounds: int = None):
"""运行Twitter模拟
"""Run Twitter simulation
Args:
max_rounds: 最大模拟轮数可选用于截断过长的模拟
max_rounds: Maximum simulation rounds (optional, used to truncate overlong simulations)
"""
print("=" * 60)
print("OASIS Twitter模拟")
print(f"配置文件: {self.config_path}")
print(f"模拟ID: {self.config.get('simulation_id', 'unknown')}")
print(f"等待命令模式: {'启用' if self.wait_for_commands else '禁用'}")
print("OASIS Twitter Simulation")
print(f"Config file: {self.config_path}")
print(f"Simulation ID: {self.config.get('simulation_id', 'unknown')}")
print(f"Wait-for-commands mode: {'enabled' if self.wait_for_commands else 'disabled'}")
print("=" * 60)
# 加载时间配置
# Load time config
time_config = self.config.get("time_config", {})
total_hours = time_config.get("total_simulation_hours", 72)
minutes_per_round = time_config.get("minutes_per_round", 30)
# 计算总轮数
# Compute total rounds
total_rounds = (total_hours * 60) // minutes_per_round
# 如果指定了最大轮数,则截断
# If a max-rounds is specified, truncate
if max_rounds is not None and max_rounds > 0:
original_rounds = total_rounds
total_rounds = min(total_rounds, max_rounds)
if total_rounds < original_rounds:
print(f"\n轮数已截断: {original_rounds} -> {total_rounds} (max_rounds={max_rounds})")
print(f"\nRounds truncated: {original_rounds} -> {total_rounds} (max_rounds={max_rounds})")
print(f"\n模拟参数:")
print(f" - 总模拟时长: {total_hours}小时")
print(f" - 每轮时间: {minutes_per_round}分钟")
print(f" - 总轮数: {total_rounds}")
print(f"\nSimulation parameters:")
print(f" - Total simulation duration: {total_hours} hours")
print(f" - Time per round: {minutes_per_round} minutes")
print(f" - Total rounds: {total_rounds}")
if max_rounds:
print(f" - 最大轮数限制: {max_rounds}")
print(f" - Agent数量: {len(self.config.get('agent_configs', []))}")
print(f" - Max rounds limit: {max_rounds}")
print(f" - Number of Agents: {len(self.config.get('agent_configs', []))}")
# 创建模型
print("\n初始化LLM模型...")
# Create model
print("\nInitializing LLM model...")
model = self._create_model()
# 加载Agent图
print("加载Agent Profile...")
# Load Agent graph
print("Loading Agent Profile...")
profile_path = self._get_profile_path()
if not os.path.exists(profile_path):
print(f"错误: Profile文件不存在: {profile_path}")
print(f"Error: Profile file does not exist: {profile_path}")
return
self.agent_graph = await generate_twitter_agent_graph(
@ -581,34 +581,34 @@ class TwitterSimulationRunner:
available_actions=self.AVAILABLE_ACTIONS,
)
# 数据库路径
# Database path
db_path = self._get_db_path()
if os.path.exists(db_path):
os.remove(db_path)
print(f"已删除旧数据库: {db_path}")
print(f"Deleted old database: {db_path}")
# 创建环境
print("创建OASIS环境...")
# Create environment
print("Creating OASIS environment...")
self.env = oasis.make(
agent_graph=self.agent_graph,
platform=oasis.DefaultPlatformType.TWITTER,
database_path=db_path,
semaphore=30, # 限制最大并发 LLM 请求数,防止 API 过载
semaphore=30, # Limit max concurrent LLM requests to prevent API overload
)
await self.env.reset()
print("环境初始化完成\n")
print("Environment initialization complete\n")
# 初始化IPC处理器
# Initialize IPC handler
self.ipc_handler = IPCHandler(self.simulation_dir, self.env, self.agent_graph)
self.ipc_handler.update_status("running")
# 执行初始事件
# Execute initial events
event_config = self.config.get("event_config", {})
initial_posts = event_config.get("initial_posts", [])
if initial_posts:
print(f"执行初始事件 ({len(initial_posts)}条初始帖子)...")
print(f"Executing initial events ({len(initial_posts)} initial posts)...")
initial_actions = {}
for post in initial_posts:
agent_id = post.get("poster_agent_id", 0)
@ -620,23 +620,23 @@ class TwitterSimulationRunner:
action_args={"content": content}
)
except Exception as e:
print(f" 警告: 无法为Agent {agent_id}创建初始帖子: {e}")
print(f" Warning: unable to create initial post for Agent {agent_id}: {e}")
if initial_actions:
await self.env.step(initial_actions)
print(f" 已发布 {len(initial_actions)} 条初始帖子")
print(f" Published {len(initial_actions)} initial posts")
# 主模拟循环
print("\n开始模拟循环...")
# Main simulation loop
print("\nStarting simulation loop...")
start_time = datetime.now()
for round_num in range(total_rounds):
# 计算当前模拟时间
# Compute current simulation time
simulated_minutes = round_num * minutes_per_round
simulated_hour = (simulated_minutes // 60) % 24
simulated_day = simulated_minutes // (60 * 24) + 1
# 获取本轮激活的Agent
# Get activated Agents for this round
active_agents = self._get_active_agents_for_round(
self.env, simulated_hour, round_num
)
@ -644,16 +644,16 @@ class TwitterSimulationRunner:
if not active_agents:
continue
# 构建动作
# Build actions
actions = {
agent: LLMAction()
for _, agent in active_agents
}
# 执行动作
# Execute actions
await self.env.step(actions)
# 打印进度
# Print progress
if (round_num + 1) % 10 == 0 or round_num == 0:
elapsed = (datetime.now() - start_time).total_seconds()
progress = (round_num + 1) / total_rounds * 100
@ -663,20 +663,20 @@ class TwitterSimulationRunner:
f"- elapsed: {elapsed:.1f}s")
total_elapsed = (datetime.now() - start_time).total_seconds()
print(f"\n模拟循环完成!")
print(f" - 总耗时: {total_elapsed:.1f}")
print(f" - 数据库: {db_path}")
print(f"\nSimulation loop completed!")
print(f" - Total elapsed: {total_elapsed:.1f} seconds")
print(f" - Database: {db_path}")
# 是否进入等待命令模式
# Whether to enter wait-for-commands mode
if self.wait_for_commands:
print("\n" + "=" * 60)
print("进入等待命令模式 - 环境保持运行")
print("支持的命令: interview, batch_interview, close_env")
print("Entering wait-for-commands mode - environment keeps running")
print("Supported commands: interview, batch_interview, close_env")
print("=" * 60)
self.ipc_handler.update_status("alive")
# 等待命令循环(使用全局 _shutdown_event
# Wait-for-commands loop (using global _shutdown_event)
try:
while not _shutdown_event.is_set():
should_continue = await self.ipc_handler.process_commands()
@ -684,58 +684,58 @@ class TwitterSimulationRunner:
break
try:
await asyncio.wait_for(_shutdown_event.wait(), timeout=0.5)
break # 收到退出信号
break # Received exit signal
except asyncio.TimeoutError:
pass
except KeyboardInterrupt:
print("\n收到中断信号")
print("\nReceived interrupt signal")
except asyncio.CancelledError:
print("\n任务被取消")
print("\nTask cancelled")
except Exception as e:
print(f"\n命令处理出错: {e}")
print(f"\nCommand processing error: {e}")
print("\n关闭环境...")
print("\nClosing environment...")
# 关闭环境
# Close the environment
self.ipc_handler.update_status("stopped")
await self.env.close()
print("环境已关闭")
print("Environment closed")
print("=" * 60)
async def main():
parser = argparse.ArgumentParser(description='OASIS Twitter模拟')
parser = argparse.ArgumentParser(description='OASIS Twitter Simulation')
parser.add_argument(
'--config',
type=str,
required=True,
help='配置文件路径 (simulation_config.json)'
help='Configuration file path (simulation_config.json)'
)
parser.add_argument(
'--max-rounds',
type=int,
default=None,
help='最大模拟轮数(可选,用于截断过长的模拟)'
help='Maximum simulation rounds (optional, used to truncate overlong simulations)'
)
parser.add_argument(
'--no-wait',
action='store_true',
default=False,
help='模拟完成后立即关闭环境,不进入等待命令模式'
help='Close environment immediately after simulation completes, do not enter wait-for-commands mode'
)
args = parser.parse_args()
# 在 main 函数开始时创建 shutdown 事件
# Create shutdown event at the start of main()
global _shutdown_event
_shutdown_event = asyncio.Event()
if not os.path.exists(args.config):
print(f"错误: 配置文件不存在: {args.config}")
print(f"Error: configuration file does not exist: {args.config}")
sys.exit(1)
# 初始化日志配置(使用固定文件名,清理旧日志)
# Initialize log config (use fixed log file names, clean up old logs)
simulation_dir = os.path.dirname(args.config) or "."
setup_oasis_logging(os.path.join(simulation_dir, "log"))
@ -748,20 +748,20 @@ async def main():
def setup_signal_handlers():
"""
设置信号处理器确保收到 SIGTERM/SIGINT 时能够正确退出
让程序有机会正常清理资源关闭数据库环境等
Set up signal handlers to ensure clean exit on SIGTERM/SIGINT
Give the program a chance to clean up resources (close DB, env, etc.)
"""
def signal_handler(signum, frame):
global _cleanup_done
sig_name = "SIGTERM" if signum == signal.SIGTERM else "SIGINT"
print(f"\n收到 {sig_name} 信号,正在退出...")
print(f"\nReceived {sig_name} signal, exiting...")
if not _cleanup_done:
_cleanup_done = True
if _shutdown_event:
_shutdown_event.set()
else:
# 重复收到信号才强制退出
print("强制退出...")
# Only force exit if the signal is received again
print("Forced exit...")
sys.exit(1)
signal.signal(signal.SIGTERM, signal_handler)
@ -773,8 +773,8 @@ if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
print("\n程序被中断")
print("\nProgram interrupted")
except SystemExit:
pass
finally:
print("模拟进程已退出")
print("Simulation process exited")

View File

@ -1,8 +1,8 @@
"""
测试Profile格式生成是否符合OASIS要求
验证
1. Twitter Profile生成CSV格式
2. Reddit Profile生成JSON详细格式
Test that the generated Profile formats conform to OASIS requirements
Verifies:
1. Twitter Profile generated as CSV format
2. Reddit Profile generated as detailed JSON format
"""
import os
@ -11,19 +11,19 @@ import json
import csv
import tempfile
# 添加项目路径
# Add project path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.services.oasis_profile_generator import OasisProfileGenerator, OasisAgentProfile
def test_profile_formats():
"""测试Profile格式"""
"""Test Profile formats"""
print("=" * 60)
print("OASIS Profile格式测试")
print("OASIS Profile format test")
print("=" * 60)
# 创建测试Profile数据
# Create test Profile data
test_profiles = [
OasisAgentProfile(
user_id=0,
@ -60,87 +60,87 @@ def test_profile_formats():
source_entity_type="University",
),
]
generator = OasisProfileGenerator.__new__(OasisProfileGenerator)
# 使用临时目录
# Use a temporary directory
with tempfile.TemporaryDirectory() as temp_dir:
twitter_path = os.path.join(temp_dir, "twitter_profiles.csv")
reddit_path = os.path.join(temp_dir, "reddit_profiles.json")
# 测试Twitter CSV格式
print("\n1. 测试Twitter Profile (CSV格式)")
# Test Twitter CSV format
print("\n1. Test Twitter Profile (CSV format)")
print("-" * 40)
generator._save_twitter_csv(test_profiles, twitter_path)
# 读取并验证CSV
# Read and verify the CSV
with open(twitter_path, 'r', encoding='utf-8') as f:
reader = csv.DictReader(f)
rows = list(reader)
print(f" 文件: {twitter_path}")
print(f" 行数: {len(rows)}")
print(f" 表头: {list(rows[0].keys())}")
print(f"\n 示例数据 (第1行):")
print(f" File: {twitter_path}")
print(f" Row count: {len(rows)}")
print(f" Header: {list(rows[0].keys())}")
print(f"\n Sample data (row 1):")
for key, value in rows[0].items():
print(f" {key}: {value}")
# 验证必需字段
required_twitter_fields = ['user_id', 'user_name', 'name', 'bio',
# Verify required fields
required_twitter_fields = ['user_id', 'user_name', 'name', 'bio',
'friend_count', 'follower_count', 'statuses_count', 'created_at']
missing = set(required_twitter_fields) - set(rows[0].keys())
if missing:
print(f"\n [错误] 缺少字段: {missing}")
print(f"\n [Error] Missing fields: {missing}")
else:
print(f"\n [通过] 所有必需字段都存在")
# 测试Reddit JSON格式
print("\n2. 测试Reddit Profile (JSON详细格式)")
print(f"\n [Pass] All required fields are present")
# Test Reddit JSON format
print("\n2. Test Reddit Profile (detailed JSON format)")
print("-" * 40)
generator._save_reddit_json(test_profiles, reddit_path)
# 读取并验证JSON
# Read and verify the JSON
with open(reddit_path, 'r', encoding='utf-8') as f:
reddit_data = json.load(f)
print(f" 文件: {reddit_path}")
print(f" 条目数: {len(reddit_data)}")
print(f" 字段: {list(reddit_data[0].keys())}")
print(f"\n 示例数据 (第1条):")
print(f" File: {reddit_path}")
print(f" Entry count: {len(reddit_data)}")
print(f" Fields: {list(reddit_data[0].keys())}")
print(f"\n Sample data (entry 1):")
print(json.dumps(reddit_data[0], ensure_ascii=False, indent=4))
# 验证详细格式字段
# Verify detailed-format fields
required_reddit_fields = ['realname', 'username', 'bio', 'persona']
optional_reddit_fields = ['age', 'gender', 'mbti', 'country', 'profession', 'interested_topics']
missing = set(required_reddit_fields) - set(reddit_data[0].keys())
if missing:
print(f"\n [错误] 缺少必需字段: {missing}")
print(f"\n [Error] Missing required fields: {missing}")
else:
print(f"\n [通过] 所有必需字段都存在")
print(f"\n [Pass] All required fields are present")
present_optional = set(optional_reddit_fields) & set(reddit_data[0].keys())
print(f" [信息] 可选字段: {present_optional}")
print(f" [Info] Optional fields: {present_optional}")
print("\n" + "=" * 60)
print("测试完成!")
print("Tests complete!")
print("=" * 60)
def show_expected_formats():
"""显示OASIS期望的格式"""
"""Show the formats expected by OASIS"""
print("\n" + "=" * 60)
print("OASIS 期望的Profile格式参考")
print("OASIS expected Profile format reference")
print("=" * 60)
print("\n1. Twitter Profile (CSV格式)")
print("\n1. Twitter Profile (CSV format)")
print("-" * 40)
twitter_example = """user_id,user_name,name,bio,friend_count,follower_count,statuses_count,created_at
0,user0,User Zero,I am user zero with interests in technology.,100,150,500,2023-01-01
1,user1,User One,Tech enthusiast and coffee lover.,200,250,1000,2023-01-02"""
print(twitter_example)
print("\n2. Reddit Profile (JSON详细格式)")
print("\n2. Reddit Profile (detailed JSON format)")
print("-" * 40)
reddit_example = [
{

File diff suppressed because it is too large Load Diff

201
backend/vendor/camel-oasis/LICENSE vendored Normal file
View File

@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2023 @ CAMEL-AI.org
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View File

@ -0,0 +1,13 @@
# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
# Licensed under the Apache License, Version 2.0 (the “License”);
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an “AS IS” BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========

View File

@ -0,0 +1,126 @@
# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
# Licensed under the Apache License, Version 2.0 (the “License”);
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an “AS IS” BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
import os
import re
import sys
from pathlib import Path
from typing import List
# The license template file is hard-coded with specific start and end lines
def fine_license_start_line(lines: List[str], start_with: str) -> int:
for i in range(len(lines)):
if lines[i].startswith(start_with):
return i
return None
def find_license_end_line(lines: List[str], start_with: str) -> int:
for i in range(len(lines) - 1, -1, -1):
if lines[i].startswith(start_with):
return i
return None
def update_license_in_file(
file_path: str,
license_template_path: str,
start_line_start_with: str,
end_line_start_with: str,
) -> bool:
with open(file_path, 'r',
encoding='utf-8') as f: # for windows compatibility
content = f.read()
with open(license_template_path, 'r', encoding='utf-8') as f:
new_license = f.read().strip()
maybe_existing_licenses = re.findall(r'^#.*?(?=\n)', content,
re.MULTILINE | re.DOTALL)
start_index = fine_license_start_line(maybe_existing_licenses,
start_line_start_with)
end_index = find_license_end_line(maybe_existing_licenses,
end_line_start_with)
if start_index is not None and end_index is not None:
maybe_existing_licenses = maybe_existing_licenses[
start_index:end_index + 1]
else:
maybe_existing_licenses = None
if maybe_existing_licenses:
maybe_old_licenses = '\n'.join(maybe_existing_licenses)
if maybe_old_licenses.strip() != new_license.strip():
replaced_content = content.replace(maybe_old_licenses, new_license)
with open(file_path, 'w') as f:
f.write(replaced_content)
print(f'Replaced license in {file_path}')
return True
else:
return False
else:
with open(file_path, 'w') as f:
f.write(new_license + '\n' + content)
print(f'Added license to {file_path}')
return True
def update_license_in_directory(
directory_path: str,
license_template_path: str,
start_line_start_with: str,
end_line_start_with: str,
) -> None:
# Check if directory exists
if not os.path.isdir(directory_path):
raise NotADirectoryError(f'{directory_path} is not a directory')
# Check if license template exists
if not os.path.isfile(license_template_path):
raise FileNotFoundError(f'{license_template_path} not found')
file_count = 0
for py_files in Path(directory_path).rglob("*.py"):
if py_files.name.startswith('.'):
continue
if any(part.startswith('.') for part in py_files.parts):
continue
if update_license_in_file(
py_files,
license_template_path,
start_line_start_with,
end_line_start_with,
):
file_count += 1
print(f'License updated in {file_count} files')
if __name__ == '__main__':
if len(sys.argv) < 3:
print(
"Usage from command line: "
"python update_license.py <directory_path> <license_template_path>"
"No valid input arguments found, please enter manually.")
directory_path = input("Enter directory path: ")
license_template_path = input("Enter license template path: ")
else:
directory_path = sys.argv[1]
license_template_path = sys.argv[2]
start_line_start_with = "# =========== Copyright"
end_line_start_with = "# =========== Copyright"
update_license_in_directory(
directory_path,
license_template_path,
start_line_start_with,
end_line_start_with,
)

337
backend/vendor/camel-oasis/README.md vendored Normal file
View File

@ -0,0 +1,337 @@
<div align="center">
<a href="https://www.camel-ai.org/">
<img src="assets/banner.png" alt=banner>
</a>
</div>
</br>
<div align="center">
<h1> OASIS: Open Agent Social Interaction Simulations with One Million Agents
</h1>
[![Documentation][docs-image]][docs-url]
[![Discord][discord-image]][discord-url]
[![X][x-image]][x-url]
[![Reddit][reddit-image]][reddit-url]
[![Wechat][wechat-image]][wechat-url]
[![Wechat][oasis-image]][oasis-url]
[![Hugging Face][huggingface-image]][huggingface-url]
[![Star][star-image]][star-url]
[![Package License][package-license-image]][package-license-url]
<h4 align="center">
[Community](https://github.com/camel-ai/camel#community) |
[Paper](https://arxiv.org/abs/2411.11581) |
[Examples](https://github.com/camel-ai/oasis/tree/main/scripts) |
[Dataset](https://huggingface.co/datasets/echo-yiyiyi/oasis-dataset) |
[Citation](https://github.com/camel-ai/oasis#-citation) |
[Contributing](https://github.com/camel-ai/oasis#-contributing-to-oasis) |
[CAMEL-AI](https://www.camel-ai.org/)
</h4>
</div>
<br>
<p align="left">
<img src='assets/intro.png'>
🏝️ OASIS is a scalable, open-source social media simulator that incorporates large language model agents to realistically mimic the behavior of up to one million users on platforms like Twitter and Reddit. It's designed to facilitate the study of complex social phenomena such as information spread, group polarization, and herd behavior, offering a versatile tool for exploring diverse social dynamics and user interactions in digital environments.
</p>
<br>
<div align="center">
🌟 Star OASIS on GitHub and be instantly notified of new releases.
</div>
<br>
<div align="center">
<img src="assets/star.gif" alt="Star" width="196" height="52">
</a>
</div>
<br>
## ✨ Key Features
### 📈 Scalability
OASIS supports simulations of up to ***one million agents***, enabling studies of social media dynamics at a scale comparable to real-world platforms.
### 📲 Dynamic Environments
Adapts to real-time changes in social networks and content, mirroring the fluid dynamics of platforms like **Twitter** and **Reddit** for authentic simulation experiences.
### 👍🏼 Diverse Action Spaces
Agents can perform **23 actions**, such as following, commenting, and reposting, allowing for rich, multi-faceted interactions.
### 🔥 Integrated Recommendation Systems
Features **interest-based** and **hot-score-based recommendation algorithms**, simulating how users discover content and interact within social media platforms.
<br>
## 📺 Demo Video
### Introducing OASIS: Open Agent Social Interaction Simulations with One Million Agents
https://github.com/user-attachments/assets/3bd2553c-d25d-4d8c-a739-1af51354b15a
<br>
For more showcaes:
- Can 1,000,000 AI agents simulate social media?
[→Watch demo](https://www.youtube.com/watch?v=lprGHqkApus&t=2s)
<br>
## 🎯 Usecase
<div align="left">
<img src="assets/research_simulation.png" alt=usecase1>
<img src="assets/interaction.png" alt=usecase2>
<a href="http://www.matrix.eigent.ai">
<img src="assets/content_creation.png" alt=usecase3>
</a>
<img src="assets/prediction.png" alt=usecase4>
</div>
## ⚙️ Quick Start
1. **Install the OASIS package:**
Installing OASIS is a breeze thanks to its availability on PyPI. Simply open your terminal and run:
```bash
pip install camel-oasis
```
2. **Set up your OpenAI API key:**
```bash
# For Bash shell (Linux, macOS, Git Bash on Windows):
export OPENAI_API_KEY=<insert your OpenAI API key>
# For Windows Command Prompt:
set OPENAI_API_KEY=<insert your OpenAI API key>
```
3. **Prepare the agent profile file:**
Create the profile you want to assign to the agent. As an example, you can download [user_data_36.json](https://github.com/camel-ai/oasis/blob/main/data/reddit/user_data_36.json) and place it in your local `./data/reddit` folder.
4. **Run the following Python code:**
```python
import asyncio
import os
from camel.models import ModelFactory
from camel.types import ModelPlatformType, ModelType
import oasis
from oasis import (ActionType, LLMAction, ManualAction,
generate_reddit_agent_graph)
async def main():
# Define the model for the agents
openai_model = ModelFactory.create(
model_platform=ModelPlatformType.OPENAI,
model_type=ModelType.GPT_4O_MINI,
)
# Define the available actions for the agents
available_actions = [
ActionType.LIKE_POST,
ActionType.DISLIKE_POST,
ActionType.CREATE_POST,
ActionType.CREATE_COMMENT,
ActionType.LIKE_COMMENT,
ActionType.DISLIKE_COMMENT,
ActionType.SEARCH_POSTS,
ActionType.SEARCH_USER,
ActionType.TREND,
ActionType.REFRESH,
ActionType.DO_NOTHING,
ActionType.FOLLOW,
ActionType.MUTE,
]
agent_graph = await generate_reddit_agent_graph(
profile_path="./data/reddit/user_data_36.json",
model=openai_model,
available_actions=available_actions,
)
# Define the path to the database
db_path = "./data/reddit_simulation.db"
# Delete the old database
if os.path.exists(db_path):
os.remove(db_path)
# Make the environment
env = oasis.make(
agent_graph=agent_graph,
platform=oasis.DefaultPlatformType.REDDIT,
database_path=db_path,
)
# Run the environment
await env.reset()
actions_1 = {}
actions_1[env.agent_graph.get_agent(0)] = [
ManualAction(action_type=ActionType.CREATE_POST,
action_args={"content": "Hello, world!"}),
ManualAction(action_type=ActionType.CREATE_COMMENT,
action_args={
"post_id": "1",
"content": "Welcome to the OASIS World!"
})
]
actions_1[env.agent_graph.get_agent(1)] = ManualAction(
action_type=ActionType.CREATE_COMMENT,
action_args={
"post_id": "1",
"content": "I like the OASIS world."
})
await env.step(actions_1)
actions_2 = {
agent: LLMAction()
for _, agent in env.agent_graph.get_agents()
}
# Perform the actions
await env.step(actions_2)
# Close the environment
await env.close()
if __name__ == "__main__":
asyncio.run(main())
```
<br>
> \[!TIP\]
> For more detailed instructions and additional configuration options, check out the [documentation](https://docs.oasis.camel-ai.org/).
### More Tutorials
To discover how to create profiles for large-scale users, as well as how to visualize and analyze social simulation data once your experiment concludes, please refer to [More Tutorials](examples/experiment/user_generation_visualization.md) for detailed guidance.
<div align="center">
<img src="assets/tutorial.png" alt="Tutorial Overview">
</div>
## 📢 News
### Upcoming Features & Contributions
> We welcome community contributions! Join us in building these exciting features.
- [Support Multi Modal Platform](https://github.com/camel-ai/oasis/issues/47)
<!-- - Public release of our dataset on Hugging Face (November 05, 2024) -->
### Latest Updates
📢 Update the camel-ai version to 0.2.78 and update the dataset HuggingFace link. - 📆 December 4, 2025
- Add the report post action to mark inappropriate content. - 📆 June 8, 2025
- Add features for creating group chats, sending messages in group chats, and leaving group chats. - 📆 June 6, 2025
- Support Interview Action for asking agents specific questions and getting answers. - 📆 June 2, 2025
- Support customization of each agent's models, tools, and prompts; refactor the interface to follow the PettingZoo style. - 📆 May 22, 2025
- Refactor into the OASIS environment, publish camel-oasis on PyPI, and release the documentation. - 📆 April 24, 2025
- Support OPENAI Embedding model for Twhin-Bert Recommendation System. - 📆 March 25, 2025
...
- Slightly refactoring the database to add Quote Action and modify Repost Action - 📆 January 13, 2025
- Added the demo video and oasis's star history in the README - 📆 January 5, 2025
- Introduced an Electronic Mall on the Reddit platform - 📆 December 5, 2024
- OASIS initially released on arXiv - 📆 November 19, 2024
- OASIS GitHub repository initially launched - 📆 November 19, 2024
## 🔎 Follow-up Research
- [MultiAgent4Collusion](https://github.com/renqibing/MultiAgent4Collusion): multi-agent collusion simulation framework in social systems
- More to come...
If your research is based on OASIS, we'd be happy to feature your work here—feel free to reach out or submit a pull request to add it to the [README](https://github.com/camel-ai/oasis/blob/main/README.md)!
## 🥂 Contributing to OASIS🏝
> We greatly appreciate your interest in contributing to our open-source initiative. To ensure a smooth collaboration and the success of contributions, we adhere to a set of contributing guidelines similar to those established by CAMEL. For a comprehensive understanding of the steps involved in contributing to our project, please refer to the OASIS [contributing guidelines](https://github.com/camel-ai/oasis/blob/master/CONTRIBUTING.md). 🤝🚀
>
> An essential part of contributing involves not only submitting new features with accompanying tests (and, ideally, examples) but also ensuring that these contributions pass our automated pytest suite. This approach helps us maintain the project's quality and reliability by verifying compatibility and functionality.
## 📬 Community & Contact
If you're keen on exploring new research opportunities or discoveries with our platform and wish to dive deeper or suggest new features, we're here to talk. Feel free to get in touch for more details at camel.ai.team@gmail.com.
<br>
- Join us ([*Discord*](https://discord.camel-ai.org/) or [*WeChat*](https://ghli.org/camel/wechat.png)) in pushing the boundaries of finding the scaling laws of agents.
- Join WechatGroup for further discussions!
<div align="">
<img src="assets/wechatgroup.png" alt="WeChat Group QR Code" width="600">
</div>
## 🌟 Star History
[![Star History Chart](https://api.star-history.com/svg?repos=camel-ai/oasis&type=Date)](https://star-history.com/#camel-ai/oasis&Date)
## 🔗 Citation
```
@misc{yang2024oasisopenagentsocial,
title={OASIS: Open Agent Social Interaction Simulations with One Million Agents},
author={Ziyi Yang and Zaibin Zhang and Zirui Zheng and Yuxian Jiang and Ziyue Gan and Zhiyu Wang and Zijian Ling and Jinsong Chen and Martz Ma and Bowen Dong and Prateek Gupta and Shuyue Hu and Zhenfei Yin and Guohao Li and Xu Jia and Lijun Wang and Bernard Ghanem and Huchuan Lu and Chaochao Lu and Wanli Ouyang and Yu Qiao and Philip Torr and Jing Shao},
year={2024},
eprint={2411.11581},
archivePrefix={arXiv},
primaryClass={cs.CL},
url={https://arxiv.org/abs/2411.11581},
}
```
## 🙌 Acknowledgment
We would like to thank Douglas for designing the logo of our project.
## 🖺 License
The source code is licensed under Apache 2.0.
[discord-image]: https://img.shields.io/discord/1082486657678311454?logo=discord&labelColor=%20%235462eb&logoColor=%20%23f5f5f5&color=%20%235462eb
[discord-url]: https://discord.camel-ai.org/
[docs-image]: https://img.shields.io/badge/Documentation-EB3ECC
[docs-url]: https://docs.oasis.camel-ai.org/
[huggingface-image]: https://img.shields.io/badge/%F0%9F%A4%97%20Hugging%20Face-CAMEL--AI-ffc107?color=ffc107&logoColor=white
[huggingface-url]: https://huggingface.co/camel-ai
[oasis-image]: https://img.shields.io/badge/WeChat-OASISProject-brightgreen?logo=wechat&logoColor=white
[oasis-url]: ./assets/wechatgroup.png
[package-license-image]: https://img.shields.io/badge/License-Apache_2.0-blue.svg
[package-license-url]: https://github.com/camel-ai/oasis/blob/main/licenses/LICENSE
[reddit-image]: https://img.shields.io/reddit/subreddit-subscribers/CamelAI?style=plastic&logo=reddit&label=r%2FCAMEL&labelColor=white
[reddit-url]: https://www.reddit.com/r/CamelAI/
[star-image]: https://img.shields.io/github/stars/camel-ai/oasis?label=stars&logo=github&color=brightgreen
[star-url]: https://github.com/camel-ai/oasis/stargazers
[wechat-image]: https://img.shields.io/badge/WeChat-CamelAIOrg-brightgreen?logo=wechat&logoColor=white
[wechat-url]: ./assets/wechat.JPGwechat.jpg
[x-image]: https://img.shields.io/twitter/follow/CamelAIOrg?style=social
[x-url]: https://x.com/CamelAIOrg

View File

@ -0,0 +1,31 @@
# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
# Licensed under the Apache License, Version 2.0 (the “License”);
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an “AS IS” BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
__version__ = "0.2.5"
from oasis.environment.env_action import LLMAction, ManualAction
from oasis.environment.make import make
from oasis.social_agent import (generate_reddit_agent_graph,
generate_twitter_agent_graph)
from oasis.social_agent.agent import SocialAgent
from oasis.social_agent.agent_graph import AgentGraph
from oasis.social_platform.config import UserInfo
from oasis.social_platform.platform import Platform
from oasis.social_platform.typing import ActionType, DefaultPlatformType
from oasis.testing.show_db import print_db_contents
__all__ = [
"make", "Platform", "ActionType", "DefaultPlatformType", "ManualAction",
"LLMAction", "print_db_contents", "AgentGraph", "SocialAgent", "UserInfo",
"generate_reddit_agent_graph", "generate_twitter_agent_graph"
]

View File

@ -0,0 +1,16 @@
# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
# Licensed under the Apache License, Version 2.0 (the “License”);
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an “AS IS” BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
from .clock import Clock
__all__ = ["Clock"]

View File

@ -0,0 +1,33 @@
# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
# Licensed under the Apache License, Version 2.0 (the “License”);
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an “AS IS” BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
from datetime import datetime
class Clock:
r"""Clock used for the sandbox."""
def __init__(self, k: int = 1):
self.real_start_time = datetime.now()
self.k = k
self.time_step = 0
def time_transfer(self, now_time: datetime,
start_time: datetime) -> datetime:
time_diff = now_time - self.real_start_time
adjusted_diff = self.k * time_diff
adjusted_time = start_time + adjusted_diff
return adjusted_time
def get_time_step(self) -> str:
return str(self.time_step)

View File

@ -0,0 +1,13 @@
# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
# Licensed under the Apache License, Version 2.0 (the “License”);
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an “AS IS” BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========

View File

@ -0,0 +1,208 @@
# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
# Licensed under the Apache License, Version 2.0 (the “License”);
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an “AS IS” BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
import asyncio
import logging
import os
from datetime import datetime
from typing import List, Union
from oasis.environment.env_action import LLMAction, ManualAction
from oasis.social_agent.agent import SocialAgent
from oasis.social_agent.agent_graph import AgentGraph
from oasis.social_agent.agents_generator import generate_custom_agents
from oasis.social_platform.channel import Channel
from oasis.social_platform.platform import Platform
from oasis.social_platform.typing import (ActionType, DefaultPlatformType,
RecsysType)
# Create log directory if it doesn't exist
log_dir = "./log"
if not os.path.exists(log_dir):
os.makedirs(log_dir)
# Configure logger
env_log = logging.getLogger("oasis.env")
env_log.setLevel("INFO")
# Add file handler to save logs to file
current_time = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
file_handler = logging.FileHandler(f"{log_dir}/oasis-{current_time}.log",
encoding="utf-8")
file_handler.setLevel("INFO")
file_handler.setFormatter(
logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s"))
env_log.addHandler(file_handler)
class OasisEnv:
def __init__(
self,
agent_graph: AgentGraph,
platform: Union[DefaultPlatformType, Platform],
database_path: str = None,
semaphore: int = 128,
) -> None:
r"""Init the oasis environment.
Args:
agent_graph: The AgentGraph to use in the simulation.
platform: The platform type to use. Including
`DefaultPlatformType.TWITTER` or `DefaultPlatformType.REDDIT`.
Or you can pass a custom `Platform` instance.
database_path: The path to create a sqlite3 database. The file
extension must be `.db` such as `twitter_simulation.db`.
"""
# Initialize the agent graph
self.agent_graph = agent_graph
# Use a semaphore to limit the number of concurrent requests
self.llm_semaphore = asyncio.Semaphore(semaphore)
if isinstance(platform, DefaultPlatformType):
if database_path is None:
raise ValueError(
"database_path is required for DefaultPlatformType")
self.platform = platform
if platform == DefaultPlatformType.TWITTER:
self.channel = Channel()
self.platform = Platform(
db_path=database_path,
channel=self.channel,
recsys_type="twhin-bert",
refresh_rec_post_count=2,
max_rec_post_len=2,
following_post_count=3,
)
self.platform_type = DefaultPlatformType.TWITTER
elif platform == DefaultPlatformType.REDDIT:
self.channel = Channel()
self.platform = Platform(
db_path=database_path,
channel=self.channel,
recsys_type="reddit",
allow_self_rating=True,
show_score=True,
max_rec_post_len=100,
refresh_rec_post_count=5,
)
self.platform_type = DefaultPlatformType.REDDIT
else:
raise ValueError(f"Invalid platform: {platform}. Only "
"DefaultPlatformType.TWITTER or "
"DefaultPlatformType.REDDIT are supported.")
elif isinstance(platform, Platform):
if database_path != platform.db_path:
env_log.warning("database_path is not the same as the "
"platform.db_path, using the platform.db_path")
self.platform = platform
self.channel = platform.channel
if platform.recsys_type == RecsysType.REDDIT:
self.platform_type = DefaultPlatformType.REDDIT
else:
self.platform_type = DefaultPlatformType.TWITTER
else:
raise ValueError(
f"Invalid platform: {platform}. You should pass a "
"DefaultPlatformType or a Platform instance.")
async def reset(self) -> None:
r"""Start the platform and sign up the agents."""
self.platform_task = asyncio.create_task(self.platform.running())
self.agent_graph = await generate_custom_agents(
channel=self.channel, agent_graph=self.agent_graph)
async def _perform_llm_action(self, agent):
r"""Send the request to the llm model and execute the action.
"""
async with self.llm_semaphore:
return await agent.perform_action_by_llm()
async def _perform_interview_action(self, agent, interview_prompt: str):
r"""Send the request to the llm model and execute the interview.
"""
async with self.llm_semaphore:
return await agent.perform_interview(interview_prompt)
async def step(
self, actions: dict[SocialAgent, Union[ManualAction, LLMAction,
List[Union[ManualAction,
LLMAction]]]]
) -> None:
r"""Update the recommendation system and perform the actions.
Args:
actions(dict[SocialAgent, Union[ManualAction, LLMAction,
List[Union[ManualAction, LLMAction]]]]): The actions to
perform, including the manual(pre-defined) actions and llm
actions.
Returns:
None
"""
# Update the recommendation system
await self.platform.update_rec_table()
env_log.info("update rec table.")
# Create tasks for both manual and LLM actions
tasks = []
for agent, action in actions.items():
if isinstance(action, list):
for single_action in action:
if isinstance(single_action, ManualAction):
if single_action.action_type == ActionType.INTERVIEW:
# Use the agent's perform_interview method for
# interview actions
interview_prompt = single_action.action_args.get(
"prompt", "")
tasks.append(
self._perform_interview_action(
agent, interview_prompt))
else:
tasks.append(
agent.perform_action_by_data(
single_action.action_type,
**single_action.action_args))
elif isinstance(single_action, LLMAction):
tasks.append(self._perform_llm_action(agent))
else:
if isinstance(action, ManualAction):
if action.action_type == ActionType.INTERVIEW:
# Use the agent's perform_interview method for
# interview actions
interview_prompt = action.action_args.get("prompt", "")
tasks.append(
self._perform_interview_action(
agent, interview_prompt))
else:
tasks.append(
agent.perform_action_by_data(
action.action_type, **action.action_args))
elif isinstance(action, LLMAction):
tasks.append(self._perform_llm_action(agent))
# Execute all tasks concurrently
await asyncio.gather(*tasks)
env_log.info("performed all actions.")
# # Control some agents to perform actions
# Update the clock
if self.platform_type == DefaultPlatformType.TWITTER:
self.platform.sandbox_clock.time_step += 1
async def close(self) -> None:
r"""Stop the platform and close the environment.
"""
await self.channel.write_to_receive_queue(
(None, None, ActionType.EXIT))
await self.platform_task
env_log.info("Simulation finished! Please check the results in the "
f"database: {self.platform.db_path}. Note that the trace "
"table stored all the actions of the agents.")

View File

@ -0,0 +1,45 @@
# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
# Licensed under the Apache License, Version 2.0 (the “License”);
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an “AS IS” BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
from dataclasses import dataclass
from typing import Any, Dict
from oasis.social_platform.typing import ActionType
@dataclass
class ManualAction:
r"""Some manual predefined social platform actions that need to be
executed by certain agents.
Args:
agent_id: The ID of the agent that will perform the action.
action: The action to perform.
args: The arguments to pass to the action. For details of each args in
each action, please refer to
`https://github.com/camel-ai/oasis/blob/main/oasis/social_agent/agent_action.py`.
"""
action_type: ActionType
action_args: Dict[str, Any]
def init(self, action_type, action_args):
self.action_type = action_type
self.action_args = action_args
@dataclass
class LLMAction:
r"""Represents actions generated by a Language Learning Model (LLM)."""
def init(self):
pass

View File

@ -0,0 +1,19 @@
# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
# Licensed under the Apache License, Version 2.0 (the “License”);
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an “AS IS” BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
from oasis.environment.env import OasisEnv
def make(*args, **kwargs):
obj = OasisEnv(*args, **kwargs)
return obj

View File

@ -0,0 +1,23 @@
# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
# Licensed under the Apache License, Version 2.0 (the “License”);
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an “AS IS” BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
from .agent import SocialAgent
from .agent_graph import AgentGraph
from .agents_generator import (generate_agents_100w,
generate_reddit_agent_graph,
generate_twitter_agent_graph)
__all__ = [
"SocialAgent", "AgentGraph", "generate_agents_100w",
"generate_reddit_agent_graph", "generate_twitter_agent_graph"
]

View File

@ -0,0 +1,321 @@
# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
# Licensed under the Apache License, Version 2.0 (the “License”);
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an “AS IS” BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
from __future__ import annotations
import inspect
import logging
import sys
from datetime import datetime
from typing import TYPE_CHECKING, Any, Callable, List, Optional, Union
from camel.agents import ChatAgent
from camel.messages import BaseMessage
from camel.models import BaseModelBackend, ModelManager
from camel.prompts import TextPrompt
from camel.toolkits import FunctionTool
from camel.types import OpenAIBackendRole
from oasis.social_agent.agent_action import SocialAction
from oasis.social_agent.agent_environment import SocialEnvironment
from oasis.social_platform import Channel
from oasis.social_platform.config import UserInfo
from oasis.social_platform.typing import ActionType
if TYPE_CHECKING:
from oasis.social_agent import AgentGraph
if "sphinx" not in sys.modules:
agent_log = logging.getLogger(name="social.agent")
agent_log.setLevel("DEBUG")
if not agent_log.handlers:
now = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
file_handler = logging.FileHandler(
f"./log/social.agent-{str(now)}.log")
file_handler.setLevel("DEBUG")
file_handler.setFormatter(
logging.Formatter(
"%(levelname)s - %(asctime)s - %(name)s - %(message)s"))
agent_log.addHandler(file_handler)
ALL_SOCIAL_ACTIONS = [action.value for action in ActionType]
class SocialAgent(ChatAgent):
r"""Social Agent."""
def __init__(self,
agent_id: int,
user_info: UserInfo,
user_info_template: TextPrompt | None = None,
channel: Channel | None = None,
model: Optional[Union[BaseModelBackend,
List[BaseModelBackend],
ModelManager]] = None,
agent_graph: "AgentGraph" = None,
available_actions: list[ActionType] = None,
tools: Optional[List[Union[FunctionTool, Callable]]] = None,
max_iteration: int = 1,
interview_record: bool = False):
self.social_agent_id = agent_id
self.user_info = user_info
self.channel = channel or Channel()
self.env = SocialEnvironment(SocialAction(agent_id, self.channel))
if user_info_template is None:
system_message_content = self.user_info.to_system_message()
else:
system_message_content = self.user_info.to_custom_system_message(
user_info_template)
system_message = BaseMessage.make_assistant_message(
role_name="system",
content=system_message_content, # system prompt
)
if not available_actions:
agent_log.info("No available actions defined, using all actions.")
self.action_tools = self.env.action.get_openai_function_list()
else:
all_tools = self.env.action.get_openai_function_list()
all_possible_actions = [tool.func.__name__ for tool in all_tools]
for action in available_actions:
action_name = action.value if isinstance(
action, ActionType) else action
if action_name not in all_possible_actions:
agent_log.warning(
f"Action {action_name} is not supported. Supported "
f"actions are: {', '.join(all_possible_actions)}")
self.action_tools = [
tool for tool in all_tools if tool.func.__name__ in [
a.value if isinstance(a, ActionType) else a
for a in available_actions
]
]
all_tools = (tools or []) + (self.action_tools or [])
super().__init__(
system_message=system_message,
model=model,
scheduling_strategy='random_model',
tools=all_tools,
)
self.max_iteration = max_iteration
self.interview_record = interview_record
self.agent_graph = agent_graph
self.test_prompt = (
"\n"
"Helen is a successful writer who usually writes popular western "
"novels. Now, she has an idea for a new novel that could really "
"make a big impact. If it works out, it could greatly "
"improve her career. But if it fails, she will have spent "
"a lot of time and effort for nothing.\n"
"\n"
"What do you think Helen should do?")
async def perform_action_by_llm(self):
# Get posts:
env_prompt = await self.env.to_text_prompt()
user_msg = BaseMessage.make_user_message(
role_name="User",
content=(
f"Please perform social media actions after observing the "
f"platform environments. Notice that don't limit your "
f"actions for example to just like the posts. "
f"Here is your social media environment: {env_prompt}"))
try:
agent_log.info(
f"Agent {self.social_agent_id} observing environment: "
f"{env_prompt}")
response = await self.astep(user_msg)
for tool_call in response.info['tool_calls']:
action_name = tool_call.tool_name
args = tool_call.args
agent_log.info(f"Agent {self.social_agent_id} performed "
f"action: {action_name} with args: {args}")
if action_name not in ALL_SOCIAL_ACTIONS:
agent_log.info(
f"Agent {self.social_agent_id} get the result: "
f"{tool_call.result}")
# Abort graph action for if 100w Agent
# self.perform_agent_graph_action(action_name, args)
return response
except Exception as e:
agent_log.error(f"Agent {self.social_agent_id} error: {e}")
return e
async def perform_test(self):
"""
doing group polarization test for all agents.
TODO: rewrite the function according to the ChatAgent.
TODO: unify the test and interview function.
"""
# user conduct test to agent
_ = BaseMessage.make_user_message(role_name="User",
content=("You are a twitter user."))
# Test memory should not be writed to memory.
# self.memory.write_record(MemoryRecord(user_msg,
# OpenAIBackendRole.USER))
openai_messages, num_tokens = self.memory.get_context()
openai_messages = ([{
"role":
self.system_message.role_name,
"content":
self.system_message.content.split("# RESPONSE FORMAT")[0],
}] + openai_messages + [{
"role": "user",
"content": self.test_prompt
}])
agent_log.info(f"Agent {self.social_agent_id}: {openai_messages}")
# NOTE: this is a temporary solution.
# Camel can not stop updating the agents' memory after stop and astep
# now.
response = await self._aget_model_response(
openai_messages=openai_messages, num_tokens=num_tokens)
content = response.output_messages[0].content
agent_log.info(
f"Agent {self.social_agent_id} receive response: {content}")
return {
"user_id": self.social_agent_id,
"prompt": openai_messages,
"content": content
}
async def perform_interview(self, interview_prompt: str):
"""
Perform an interview with the agent.
"""
# user conduct test to agent
user_msg = BaseMessage.make_user_message(
role_name="User", content=("You are a twitter user."))
if self.interview_record:
# Test memory should not be writed to memory.
self.update_memory(message=user_msg, role=OpenAIBackendRole.SYSTEM)
openai_messages, num_tokens = self.memory.get_context()
openai_messages = ([{
"role":
self.system_message.role_name,
"content":
self.system_message.content.split("# RESPONSE FORMAT")[0],
}] + openai_messages + [{
"role": "user",
"content": interview_prompt
}])
agent_log.info(f"Agent {self.social_agent_id}: {openai_messages}")
# NOTE: this is a temporary solution.
# Camel can not stop updating the agents' memory after stop and astep
# now.
response = await self._aget_model_response(
openai_messages=openai_messages, num_tokens=num_tokens)
content = response.output_messages[0].content
if self.interview_record:
# Test memory should not be writed to memory.
self.update_memory(message=response.output_messages[0],
role=OpenAIBackendRole.USER)
agent_log.info(
f"Agent {self.social_agent_id} receive response: {content}")
# Record the complete interview (prompt + response) through the channel
interview_data = {"prompt": interview_prompt, "response": content}
result = await self.env.action.perform_action(
interview_data, ActionType.INTERVIEW.value)
# Return the combined result
return {
"user_id": self.social_agent_id,
"prompt": openai_messages,
"content": content,
"success": result.get("success", False)
}
async def perform_action_by_hci(self) -> Any:
print("Please choose one function to perform:")
function_list = self.env.action.get_openai_function_list()
for i in range(len(function_list)):
agent_log.info(f"Agent {self.social_agent_id} function: "
f"{function_list[i].func.__name__}")
selection = int(input("Enter your choice: "))
if not 0 <= selection < len(function_list):
agent_log.error(f"Agent {self.social_agent_id} invalid input.")
return
func = function_list[selection].func
params = inspect.signature(func).parameters
args = []
for param in params.values():
while True:
try:
value = input(f"Enter value for {param.name}: ")
args.append(value)
break
except ValueError:
agent_log.error("Invalid input, please enter an integer.")
result = await func(*args)
return result
async def perform_action_by_data(self, func_name, *args, **kwargs) -> Any:
func_name = func_name.value if isinstance(func_name,
ActionType) else func_name
function_list = self.env.action.get_openai_function_list()
for i in range(len(function_list)):
if function_list[i].func.__name__ == func_name:
func = function_list[i].func
result = await func(*args, **kwargs)
self.update_memory(message=BaseMessage.make_user_message(
role_name=OpenAIBackendRole.SYSTEM,
content=f"Agent {self.social_agent_id} performed "
f"{func_name} with args: {args} and kwargs: {kwargs}"
f"and the result is {result}"),
role=OpenAIBackendRole.SYSTEM)
agent_log.info(f"Agent {self.social_agent_id}: {result}")
return result
raise ValueError(f"Function {func_name} not found in the list.")
def perform_agent_graph_action(
self,
action_name: str,
arguments: dict[str, Any],
):
r"""Remove edge if action is unfollow or add edge
if action is follow to the agent graph.
"""
if "unfollow" in action_name:
followee_id: int | None = arguments.get("followee_id", None)
if followee_id is None:
return
self.agent_graph.remove_edge(self.social_agent_id, followee_id)
agent_log.info(
f"Agent {self.social_agent_id} unfollowed Agent {followee_id}")
elif "follow" in action_name:
followee_id: int | None = arguments.get("followee_id", None)
if followee_id is None:
return
self.agent_graph.add_edge(self.social_agent_id, followee_id)
agent_log.info(
f"Agent {self.social_agent_id} followed Agent {followee_id}")
def __str__(self) -> str:
return (f"{self.__class__.__name__}(agent_id={self.social_agent_id}, "
f"model_type={self.model_type.value})")

View File

@ -0,0 +1,758 @@
# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
# Licensed under the Apache License, Version 2.0 (the “License”);
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an “AS IS” BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
from typing import Any
from camel.toolkits import FunctionTool
from oasis.social_platform.channel import Channel
from oasis.social_platform.typing import ActionType
class SocialAction:
def __init__(self, agent_id: int, channel: Channel):
self.agent_id = agent_id
self.channel = channel
def get_openai_function_list(self) -> list[FunctionTool]:
return [
FunctionTool(func) for func in [
self.create_post,
self.like_post,
self.repost,
self.quote_post,
self.unlike_post,
self.dislike_post,
self.undo_dislike_post,
self.search_posts,
self.search_user,
self.trend,
self.refresh,
self.do_nothing,
self.create_comment,
self.like_comment,
self.dislike_comment,
self.unlike_comment,
self.undo_dislike_comment,
self.follow,
self.unfollow,
self.mute,
self.unmute,
self.purchase_product,
self.interview,
self.report_post,
self.join_group,
self.leave_group,
self.send_to_group,
self.create_group,
self.listen_from_group,
]
]
async def perform_action(self, message: Any, type: str):
message_id = await self.channel.write_to_receive_queue(
(self.agent_id, message, type))
response = await self.channel.read_from_send_queue(message_id)
return response[2]
async def sign_up(self, user_name: str, name: str, bio: str):
r"""Signs up a new user with the provided username, name, and bio.
This method prepares a user message comprising the user's details and
invokes an asynchronous action to perform the sign-up process. On
successful execution, it returns a dictionary indicating success along
with the newly created user ID.
Args:
user_name (str): The username for the new user.
name (str): The full name of the new user.
bio (str): A brief biography of the new user.
Returns:
dict: A dictionary with two key-value pairs. The 'success' key
maps to a boolean indicating whether the sign-up was
successful, and 'user_id' key maps to the integer ID of the
newly created user on success.
Example of a successful return:
{'success': True, 'user_id': 2}
"""
# print(f"Agent {self.agent_id} is signing up with "
# f"user_name: {user_name}, name: {name}, bio: {bio}")
user_message = (user_name, name, bio)
return await self.perform_action(user_message, ActionType.SIGNUP.value)
async def refresh(self):
r"""Refresh to get recommended posts.
This method invokes an asynchronous action to refresh and fetch
recommended posts. On successful execution, it returns a dictionary
indicating success along with a list of posts. Each post in the list
contains details such as post ID, user ID, content, creation date,
and the number of likes.
Returns:
dict: A dictionary with two key-value pairs. The 'success' key
maps to a boolean indicating whether the refresh is
successful. The 'posts' key maps to a list of dictionaries,
each representing a post with its details.
Example of a successful return:
{
"success": True,
"posts": [
{
"post_id": 1,
"user_id": 23,
"content": "This is an example post content.",
"created_at": "2024-05-14T12:00:00Z",
"num_likes": 5
},
{
"post_id": 2,
"user_id": 42,
"content": "Another example post content.",
"created_at": "2024-05-14T12:05:00Z",
"num_likes": 15
}
]
}
"""
return await self.perform_action(None, ActionType.REFRESH.value)
async def do_nothing(self):
"""Perform no action.
Returns:
dict: A dictionary with 'success' indicating if the removal was
successful.
Example of a successful return:
{"success": True}
"""
return await self.perform_action(None, ActionType.DO_NOTHING.value)
async def create_post(self, content: str):
r"""Create a new post with the given content.
This method invokes an asynchronous action to create a new post based
on the provided content. Upon successful execution, it returns a
dictionary indicating success and the ID of the newly created post.
Args:
content (str): The content of the post to be created.
Returns:
dict: A dictionary with two key-value pairs. The 'success' key
maps to a boolean indicating whether the post creation was
successful. The 'post_id' key maps to the integer ID of the
newly created post.
Example of a successful return:
{'success': True, 'post_id': 50}
"""
return await self.perform_action(content, ActionType.CREATE_POST.value)
async def repost(self, post_id: int):
r"""Repost a specified post.
This method invokes an asynchronous action to Repost a specified
post. It is identified by the given post ID. Upon successful
execution, it returns a dictionary indicating success and the ID of
the newly created repost.
Args:
post_id (int): The ID of the post to be reposted.
Returns:
dict: A dictionary with two key-value pairs. The 'success' key
maps to a boolean indicating whether the Repost creation was
successful. The 'post_id' key maps to the integer ID of the
newly created repost.
Example of a successful return:
{"success": True, "post_id": 123}
Note:
Attempting to repost a post that the user has already reposted
will result in a failure.
"""
return await self.perform_action(post_id, ActionType.REPOST.value)
async def quote_post(self, post_id: int, quote_content: str):
r"""Quote a specified post with a given quote content.
This method invokes an asynchronous action to quote a specified post
with a given quote content. Upon successful execution, it returns a
dictionary indicating success and the ID of the newly created quote.
Args:
post_id (int): The ID of the post to be quoted.
quote_content (str): The content of the quote to be created.
Returns:
dict: A dictionary with two key-value pairs. The 'success' key
maps to a boolean indicating whether the quote creation was
successful. The 'post_id' key maps to the integer ID of the
newly created quote.
Example of a successful return:
{"success": True, "post_id": 123}
Note:
Attempting to quote a post that the user has already quoted will
result in a failure.
"""
quote_message = (post_id, quote_content)
return await self.perform_action(quote_message, ActionType.QUOTE_POST)
async def like_post(self, post_id: int):
r"""Create a new like for a specified post.
This method invokes an asynchronous action to create a new like for a
post. It is identified by the given post ID. Upon successful
execution, it returns a dictionary indicating success and the ID of
the newly created like.
Args:
post_id (int): The ID of the post to be liked.
Returns:
dict: A dictionary with two key-value pairs. The 'success' key
maps to a boolean indicating whether the like creation was
successful. The 'like_id' key maps to the integer ID of the
newly created like.
Example of a successful return:
{"success": True, "like_id": 123}
Note:
Attempting to like a post that the user has already liked will
result in a failure.
"""
return await self.perform_action(post_id, ActionType.LIKE_POST.value)
async def unlike_post(self, post_id: int):
"""Remove a like for a post.
This method removes a like from the database, identified by the
post's ID. It returns a dictionary indicating the success of the
operation and the ID of the removed like.
Args:
post_id (int): The ID of the post to be unliked.
Returns:
dict: A dictionary with 'success' indicating if the removal was
successful, and 'like_id' the ID of the removed like.
Example of a successful return:
{"success": True, "like_id": 123}
Note:
Attempting to remove a like for a post that the user has not
previously liked will result in a failure.
"""
return await self.perform_action(post_id, ActionType.UNLIKE_POST.value)
async def dislike_post(self, post_id: int):
r"""Create a new dislike for a specified post.
This method invokes an asynchronous action to create a new dislike for
a post. It is identified by the given post ID. Upon successful
execution, it returns a dictionary indicating success and the ID of
the newly created dislike.
Args:
post_id (int): The ID of the post to be disliked.
Returns:
dict: A dictionary with two key-value pairs. The 'success' key
maps to a boolean indicating whether the dislike creation was
successful. The 'dislike_id' key maps to the integer ID of the
newly created like.
Example of a successful return:
{"success": True, "dislike_id": 123}
Note:
Attempting to dislike a post that the user has already liked will
result in a failure.
"""
return await self.perform_action(post_id,
ActionType.DISLIKE_POST.value)
async def undo_dislike_post(self, post_id: int):
"""Remove a dislike for a post.
This method removes a dislike from the database, identified by the
post's ID. It returns a dictionary indicating the success of the
operation and the ID of the removed dislike.
Args:
post_id (int): The ID of the post to be unliked.
Returns:
dict: A dictionary with 'success' indicating if the removal was
successful, and 'dislike_id' the ID of the removed like.
Example of a successful return:
{"success": True, "dislike_id": 123}
Note:
Attempting to remove a dislike for a post that the user has not
previously liked will result in a failure.
"""
return await self.perform_action(post_id,
ActionType.UNDO_DISLIKE_POST.value)
async def search_posts(self, query: str):
r"""Search posts based on a given query.
This method performs a search operation in the database for posts
that match the given query string. The search considers the
post's content, post ID, and user ID. It returns a dictionary
indicating the operation's success and, if successful, a list of
posts that match the query.
Args:
query (str): The search query string. The search is performed
against the post's content, post ID, and user ID.
Returns:
dict: A dictionary with a 'success' key indicating the operation's
success. On success, it includes a 'posts' key with a list of
dictionaries, each representing a post. On failure, it
includes an 'error' message or a 'message' indicating no
posts were found.
Example of a successful return:
{
"success": True,
"posts": [
{
"post_id": 1,
"user_id": 42,
"content": "Hello, world!",
"created_at": "2024-05-14T12:00:00Z",
"num_likes": 150
},
...
]
}
"""
return await self.perform_action(query, ActionType.SEARCH_POSTS.value)
async def search_user(self, query: str):
r"""Search users based on a given query.
This asynchronous method performs a search operation in the database
for users that match the given query string. The search considers the
user's username, name, bio, and user ID. It returns a dictionary
indicating the operation's success and, if successful, a list of users
that match the query.
Args:
query (str): The search query string. The search is performed
against the user's username, name, bio, and user ID.
Returns:
dict: A dictionary with a 'success' key indicating the operation's
success. On success, it includes a 'users' key with a list of
dictionaries, each representing a user. On failure, it includes
an 'error' message or a 'message' indicating no users were
found.
Example of a successful return:
{
"success": True,
"users": [
{
"user_id": 1,
"user_name": "exampleUser",
"name": "John Doe",
"bio": "This is an example bio",
"created_at": "2024-05-14T12:00:00Z",
"num_followings": 100,
"num_followers": 150
},
...
]
}
"""
return await self.perform_action(query, ActionType.SEARCH_USER.value)
async def follow(self, followee_id: int):
r"""Follow a user.
This method allows agent to follow another user (followee).
It checks if the agent initiating the follow request has a
corresponding user ID and if the follow relationship already exists.
Args:
followee_id (int): The user ID of the user to be followed.
Returns:
dict: A dictionary with a 'success' key indicating the operation's
success. On success, it includes a 'follow_id' key with the ID
of the newly created follow record. On failure, it includes an
'error' message.
Example of a successful return:
{"success": True, "follow_id": 123}
"""
return await self.perform_action(followee_id, ActionType.FOLLOW.value)
async def unfollow(self, followee_id: int):
r"""Unfollow a user.
This method allows agent to unfollow another user (followee). It
checks if the agent initiating the unfollow request has a
corresponding user ID and if the follow relationship exists. If so,
it removes the follow record from the database, updates the followers
and followings count for both users, and logs the action.
Args:
followee_id (int): The user ID of the user to be unfollowed.
Returns:
dict: A dictionary with a 'success' key indicating the operation's
success. On success, it includes a 'follow_id' key with the ID
of the removed follow record. On failure, it includes an
'error' message.
Example of a successful return:
{"success": True, "follow_id": 123}
"""
return await self.perform_action(followee_id,
ActionType.UNFOLLOW.value)
async def mute(self, mutee_id: int):
r"""Mute a user.
Allows agent to mute another user. Checks for an existing mute
record before adding a new one to the database.
Args:
mutee_id (int): ID of the user to be muted.
Returns:
dict: On success, returns a dictionary with 'success': True and
mute_id' of the new record. On failure, returns 'success':
False and an 'error' message.
Example of a successful return:
{"success": True, "mutee_id": 123}
"""
return await self.perform_action(mutee_id, ActionType.MUTE.value)
async def unmute(self, mutee_id: int):
r"""Unmute a user.
Allows agent to remove a mute on another user. Checks for an
existing mute record before removing it from the database.
Args:
mutee_id (int): ID of the user to be unmuted.
Returns:
dict: On success, returns a dictionary with 'success': True and
'mutee_id' of the unmuted record. On failure, returns
'success': False and an 'error' message.
Example of a successful return:
{"success": True, "mutee_id": 123}
"""
return await self.perform_action(mutee_id, ActionType.UNMUTE.value)
async def trend(self):
r"""Fetch the trending posts within a predefined time period.
Retrieves the top K posts with the most likes in the last specified
number of days.
Returns:
dict: On success, returns a dictionary with 'success': True and a
list of 'posts', each post being a dictionary containing
'post_id', 'user_id', 'content', 'created_at', and
'num_likes'. On failure, returns 'success': False and an
'error' message or a message indicating no trending posts
were found.
Example of a successful return:
{
"success": True,
"posts": [
{
"post_id": 123,
"user_id": 456,
"content": "Example post content",
"created_at": "2024-05-14T12:00:00",
"num_likes": 789
},
...
]
}
"""
return await self.perform_action(None, ActionType.TREND.value)
async def create_comment(self, post_id: int, content: str):
r"""Create a new comment for a specified post given content.
This method creates a new comment based on the provided content and
associates it with the given post ID. Upon successful execution, it
returns a dictionary indicating success and the ID of the newly created
comment.
Args:
post_id (int): The ID of the post to which the comment is to be
added.
content (str): The content of the comment to be created.
Returns:
dict: A dictionary with two key-value pairs. The 'success' key
maps to a boolean indicating whether the comment creation was
successful. The 'comment_id' key maps to the integer ID of the
newly created comment.
Example of a successful return:
{'success': True, 'comment_id': 123}
"""
comment_message = (post_id, content)
return await self.perform_action(comment_message,
ActionType.CREATE_COMMENT.value)
async def like_comment(self, comment_id: int):
r"""Create a new like for a specified comment.
This method invokes an action to create a new like for a comment,
identified by the given comment ID. Upon successful execution, it
returns a dictionary indicating success and the ID of the newly
created like.
Args:
comment_id (int): The ID of the comment to be liked.
Returns:
dict: A dictionary with two key-value pairs. The 'success' key
maps to a boolean indicating whether the like creation was
successful. The 'like_id' key maps to the integer ID of the
newly created like.
Example of a successful return:
{"success": True, "comment_like_id": 456}
Note:
Attempting to like a comment that the user has already liked will
result in a failure.
"""
return await self.perform_action(comment_id,
ActionType.LIKE_COMMENT.value)
async def unlike_comment(self, comment_id: int):
"""Remove a like for a comment based on the comment's ID.
This method removes a like from the database, identified by the
comment's ID. It returns a dictionary indicating the success of the
operation and the ID of the removed like.
Args:
comment_id (int): The ID of the comment to be unliked.
Returns:
dict: A dictionary with 'success' indicating if the removal was
successful, and 'like_id' the ID of the removed like.
Example of a successful return:
{"success": True, "like_id": 456}
Note:
Attempting to remove a like for a comment that the user has not
previously liked will result in a failure.
"""
return await self.perform_action(comment_id,
ActionType.UNLIKE_COMMENT.value)
async def dislike_comment(self, comment_id: int):
r"""Create a new dislike for a specified comment.
This method invokes an action to create a new dislike for a
comment, identified by the given comment ID. Upon successful execution,
it returns a dictionary indicating success and the ID of the newly
created dislike.
Args:
comment_id (int): The ID of the comment to be disliked.
Returns:
dict: A dictionary with two key-value pairs. The 'success' key
maps to a boolean indicating whether the dislike creation was
successful. The 'dislike_id' key maps to the integer ID of the
newly created dislike.
Example of a successful return:
{"success": True, "comment_dislike_id": 456}
Note:
Attempting to dislike a comment that the user has already liked
will result in a failure.
"""
return await self.perform_action(comment_id,
ActionType.DISLIKE_COMMENT.value)
async def undo_dislike_comment(self, comment_id: int):
"""Remove a dislike for a comment.
This method removes a dislike from the database, identified by the
comment's ID. It returns a dictionary indicating the success of the
operation and the ID of the removed dislike.
Args:
comment_id (int): The ID of the comment to have the dislike
removed.
Returns:
dict: A dictionary with 'success' indicating if the removal was
successful, and 'dislike_id' the ID of the removed dislike.
Example of a successful return:
{"success": True, "dislike_id": 456}
Note:
Attempting to remove a dislike for a comment that the user has not
previously disliked will result in a failure.
"""
return await self.perform_action(comment_id,
ActionType.UNDO_DISLIKE_COMMENT.value)
async def purchase_product(self, product_name: str, purchase_num: int):
r"""Purchase a product.
Args:
product_name (str): The name of the product to be purchased.
purchase_num (int): The number of products to be purchased.
Returns:
dict: A dictionary with 'success' indicating if the purchase was
successful.
"""
purchase_message = (product_name, purchase_num)
return await self.perform_action(purchase_message,
ActionType.PURCHASE_PRODUCT.value)
async def interview(self, prompt: str):
r"""Interview an agent with the given prompt.
This method invokes an asynchronous action to interview an agent with a
specific prompt question. Upon successful execution,
it returns a dictionary containing a success status
and an interview_id for tracking.
Args:
prompt (str): The interview question or prompt to ask the agent.
Returns:
dict: A dictionary containing success status and an interview_id.
Example of a successful return:
{
"success": True,
"interview_id": "1621234567_0" # Timestamp_UserID format
}
"""
return await self.perform_action(prompt, ActionType.INTERVIEW.value)
async def report_post(self, post_id: int, report_reason: str):
r"""Report a specified post with a given reason.
This method invokes an asynchronous action to report a specified post
with a given reason. Upon successful execution, it returns a
dictionary indicating success and the ID of the newly created report.
Args:
post_id (int): The ID of the post to be reported.
report_reason (str): The reason for reporting the post.
Returns:
dict: A dictionary with two key-value pairs. The 'success' key
maps to a boolean indicating whether the report creation was
successful. The 'report_id' key maps to the integer ID of the
newly created report.
Example of a successful return:
{"success": True, "report_id": 123}
Note:
Attempting to report a post that the user has already reported will
result in a failure.
"""
report_message = (post_id, report_reason)
return await self.perform_action(report_message,
ActionType.REPORT_POST.value)
async def create_group(self, group_name: str):
r"""Creates a new group on the platform.
Args:
group_name (str): The name of the group to be created.
Returns:
dict: Platform response indicating success or failure,
e.g.{"success": True, "group_id": 1}
"""
return await self.perform_action(group_name,
ActionType.CREATE_GROUP.value)
async def join_group(self, group_id: int):
r"""Joins a group with the specified ID.
Args:
group_id (int): The ID of the group to join.
Returns:
dict: Platform response indicating success or failure,
e.g. {"success": True}
"""
return await self.perform_action(group_id, ActionType.JOIN_GROUP.value)
async def leave_group(self, group_id: int):
r"""Leaves a group with the specified ID.
Args:
group_id (int): The ID of the group to leave.
Returns:
dict: Platform response indicating success or failure, e.g.
{"success": True}
"""
return await self.perform_action(group_id,
ActionType.LEAVE_GROUP.value)
async def send_to_group(self, group_id: int, message: str):
r"""Sends a message to a specific group.
Args:
group_id (int): The ID of the target group.
message (str): The content of the message to send.
Returns:
dict: Platform response indicating success or failure, e.g.
{"success": True, "message_id": 123}
"""
return await self.perform_action((group_id, message),
ActionType.SEND_TO_GROUP.value)
async def listen_from_group(self):
r"""Listen messages from groups"""
return await self.perform_action(self.agent_id,
ActionType.LISTEN_FROM_GROUP.value)

View File

@ -0,0 +1,135 @@
# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
# Licensed under the Apache License, Version 2.0 (the “License”);
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an “AS IS” BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
from __future__ import annotations
import json
import sqlite3
from abc import ABC, abstractmethod
from string import Template
from oasis.social_agent.agent_action import SocialAction
from oasis.social_platform.database import get_db_path
class Environment(ABC):
@abstractmethod
def to_text_prompt(self) -> str:
r"""Convert the environment to text prompt."""
raise NotImplementedError
class SocialEnvironment(Environment):
followers_env_template = Template("I have $num_followers followers.")
follows_env_template = Template("I have $num_follows follows.")
posts_env_template = Template(
"After refreshing, you see some posts $posts")
groups_env_template = Template(
"And there are many group chat channels $all_groups\n"
"And You are already in some groups $joined_groups\n"
"You receive some messages from them $messages\n"
"You can join the groups you are interested, "
"leave the groups you already in, send messages to the group "
"you already in.\n"
"You must make sure you can only send messages to the group you "
"are already in")
env_template = Template(
"$groups_env\n"
"$posts_env\npick one you want to perform action that best "
"reflects your current inclination based on your profile and "
"posts content. Do not limit your action in just `like` to like posts")
def __init__(self, action: SocialAction):
self.action = action
async def get_posts_env(self) -> str:
posts = await self.action.refresh()
# TODO: Replace posts json format string to other formats
if posts["success"]:
posts_env = json.dumps(posts["posts"], indent=4)
posts_env = self.posts_env_template.substitute(posts=posts_env)
else:
posts_env = "After refreshing, there are no existing posts."
return posts_env
async def get_followers_env(self) -> str:
# TODO: Implement followers env
agent_id = self.action.agent_id
db_path = get_db_path()
try:
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
cursor.execute("SELECT num_followers FROM user WHERE agent_id = ?",
(agent_id, ))
result = cursor.fetchone()
num_followers = result[0] if result else 0
conn.close()
except Exception:
num_followers = 0
return self.followers_env_template.substitute(
{"num_followers": num_followers})
async def get_follows_env(self) -> str:
# TODO: Implement follows env
agent_id = self.action.agent_id
try:
db_path = get_db_path()
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
cursor.execute(
"SELECT num_followings FROM user WHERE agent_id = ?",
(agent_id, ))
result = cursor.fetchone()
num_followings = result[0] if result else 0
conn.close()
except Exception:
num_followings = 0
return self.follows_env_template.substitute(
{"num_follows": num_followings})
async def get_group_env(self) -> str:
groups = await self.action.listen_from_group()
if groups["success"]:
all_groups = json.dumps(groups["all_groups"])
joined_groups = json.dumps(groups["joined_groups"])
messages = json.dumps(groups["messages"])
groups_env = self.groups_env_template.substitute(
all_groups=all_groups,
joined_groups=joined_groups,
messages=messages,
)
else:
groups_env = "No groups."
return groups_env
async def to_text_prompt(
self,
include_posts: bool = True,
include_followers: bool = True,
include_follows: bool = True,
) -> str:
followers_env = (await self.get_followers_env()
if include_follows else "No followers.")
follows_env = (await self.get_follows_env()
if include_followers else "No follows.")
posts_env = await self.get_posts_env() if include_posts else ""
return self.env_template.substitute(
followers_env=followers_env,
follows_env=follows_env,
posts_env=posts_env,
groups_env=await self.get_group_env(),
)

View File

@ -0,0 +1,292 @@
# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
# Licensed under the Apache License, Version 2.0 (the “License”);
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an “AS IS” BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
from __future__ import annotations
from typing import Any, Literal
import igraph as ig
from neo4j import GraphDatabase
from oasis.social_agent.agent import SocialAgent
from oasis.social_platform.config import Neo4jConfig
class Neo4jHandler:
def __init__(self, nei4j_config: Neo4jConfig):
self.driver = GraphDatabase.driver(
nei4j_config.uri,
auth=(nei4j_config.username, nei4j_config.password),
)
self.driver.verify_connectivity()
def close(self):
self.driver.close()
def create_agent(self, agent_id: int):
with self.driver.session() as session:
session.write_transaction(self._create_and_return_agent, agent_id)
def delete_agent(self, agent_id: int):
with self.driver.session() as session:
session.write_transaction(
self._delete_agent_and_relationships,
agent_id,
)
def get_number_of_nodes(self) -> int:
with self.driver.session() as session:
return session.read_transaction(self._get_number_of_nodes)
def get_number_of_edges(self) -> int:
with self.driver.session() as session:
return session.read_transaction(self._get_number_of_edges)
def add_edge(self, src_agent_id: int, dst_agent_id: int):
with self.driver.session() as session:
session.write_transaction(
self._add_and_return_edge,
src_agent_id,
dst_agent_id,
)
def remove_edge(self, src_agent_id: int, dst_agent_id: int):
with self.driver.session() as session:
session.write_transaction(
self._remove_and_return_edge,
src_agent_id,
dst_agent_id,
)
def get_all_nodes(self) -> list[int]:
with self.driver.session() as session:
return session.read_transaction(self._get_all_nodes)
def get_all_edges(self) -> list[tuple[int, int]]:
with self.driver.session() as session:
return session.read_transaction(self._get_all_edges)
def reset_graph(self):
with self.driver.session() as session:
session.write_transaction(self._reset_graph)
@staticmethod
def _create_and_return_agent(tx: Any, agent_id: int):
query = """
CREATE (a:Agent {id: $agent_id})
RETURN a
"""
result = tx.run(query, agent_id=agent_id)
return result.single()
@staticmethod
def _delete_agent_and_relationships(tx: Any, agent_id: int):
query = """
MATCH (a:Agent {id: $agent_id})
DETACH DELETE a
RETURN count(a) AS deleted
"""
result = tx.run(query, agent_id=agent_id)
return result.single()
@staticmethod
def _add_and_return_edge(tx: Any, src_agent_id: int, dst_agent_id: int):
query = """
MATCH (a:Agent {id: $src_agent_id}), (b:Agent {id: $dst_agent_id})
CREATE (a)-[r:FOLLOW]->(b)
RETURN r
"""
result = tx.run(query,
src_agent_id=src_agent_id,
dst_agent_id=dst_agent_id)
return result.single()
@staticmethod
def _remove_and_return_edge(tx: Any, src_agent_id: int, dst_agent_id: int):
query = """
MATCH (a:Agent {id: $src_agent_id})
MATCH (b:Agent {id: $dst_agent_id})
MATCH (a)-[r:FOLLOW]->(b)
DELETE r
RETURN count(r) AS deleted
"""
result = tx.run(query,
src_agent_id=src_agent_id,
dst_agent_id=dst_agent_id)
return result.single()
@staticmethod
def _get_number_of_nodes(tx: Any) -> int:
query = """
MATCH (n)
RETURN count(n) AS num_nodes
"""
result = tx.run(query)
return result.single()["num_nodes"]
@staticmethod
def _get_number_of_edges(tx: Any) -> int:
query = """
MATCH ()-[r]->()
RETURN count(r) AS num_edges
"""
result = tx.run(query)
return result.single()["num_edges"]
@staticmethod
def _get_all_nodes(tx: Any) -> list[int]:
query = """
MATCH (a:Agent)
RETURN a.id AS agent_id
"""
result = tx.run(query)
return [record["agent_id"] for record in result]
@staticmethod
def _get_all_edges(tx: Any) -> list[tuple[int, int]]:
query = """
MATCH (a:Agent)-[r:FOLLOW]->(b:Agent)
RETURN a.id AS src_agent_id, b.id AS dst_agent_id
"""
result = tx.run(query)
return [(record["src_agent_id"], record["dst_agent_id"])
for record in result]
@staticmethod
def _reset_graph(tx: Any):
query = """
MATCH (n)
DETACH DELETE n
"""
tx.run(query)
class AgentGraph:
r"""AgentGraph class to manage the social graph of agents."""
def __init__(
self,
backend: Literal["igraph", "neo4j"] = "igraph",
neo4j_config: Neo4jConfig | None = None,
):
self.backend = backend
if self.backend == "igraph":
self.graph = ig.Graph(directed=True)
else:
assert neo4j_config is not None
assert neo4j_config.is_valid()
self.graph = Neo4jHandler(neo4j_config)
self.agent_mappings: dict[int, SocialAgent] = {}
def reset(self):
if self.backend == "igraph":
self.graph = ig.Graph(directed=True)
else:
self.graph.reset_graph()
self.agent_mappings: dict[int, SocialAgent] = {}
def add_agent(self, agent: SocialAgent):
if self.backend == "igraph":
self.graph.add_vertex(agent.social_agent_id)
else:
self.graph.create_agent(agent.social_agent_id)
self.agent_mappings[agent.social_agent_id] = agent
def add_edge(self, agent_id_0: int, agent_id_1: int):
try:
self.graph.add_edge(agent_id_0, agent_id_1)
except Exception:
pass
def remove_agent(self, agent: SocialAgent):
if self.backend == "igraph":
self.graph.delete_vertices(agent.social_agent_id)
else:
self.graph.delete_agent(agent.social_agent_id)
del self.agent_mappings[agent.social_agent_id]
def remove_edge(self, agent_id_0: int, agent_id_1: int):
if self.backend == "igraph":
if self.graph.are_connected(agent_id_0, agent_id_1):
self.graph.delete_edges([(agent_id_0, agent_id_1)])
else:
self.graph.remove_edge(agent_id_0, agent_id_1)
def get_agent(self, agent_id: int) -> SocialAgent:
return self.agent_mappings[agent_id]
def get_agents(
self,
agent_ids: list[int] = None) -> list[tuple[int, SocialAgent]]:
if agent_ids:
return [(agent_id, self.get_agent(agent_id))
for agent_id in agent_ids]
if self.backend == "igraph":
return [(node.index, self.agent_mappings[node.index])
for node in self.graph.vs]
else:
return [(agent_id, self.agent_mappings[agent_id])
for agent_id in self.graph.get_all_nodes()]
def get_edges(self) -> list[tuple[int, int]]:
if self.backend == "igraph":
return [(edge.source, edge.target) for edge in self.graph.es]
else:
return self.graph.get_all_edges()
def get_num_nodes(self) -> int:
if self.backend == "igraph":
return self.graph.vcount()
else:
return self.graph.get_number_of_nodes()
def get_num_edges(self) -> int:
if self.backend == "igraph":
return self.graph.ecount()
else:
return self.graph.get_number_of_edges()
def close(self) -> None:
if self.backend == "neo4j":
self.graph.close()
def visualize(
self,
path: str,
vertex_size: int = 20,
edge_arrow_size: float = 0.5,
with_labels: bool = True,
vertex_color: str = "#f74f1b",
vertex_frame_width: int = 2,
width: int = 1000,
height: int = 1000,
):
if self.backend == "neo4j":
raise ValueError("Neo4j backend does not support visualization.")
layout = self.graph.layout("auto")
if with_labels:
labels = [node_id for node_id, _ in self.get_agents()]
else:
labels = None
ig.plot(
self.graph,
target=path,
layout=layout,
vertex_label=labels,
vertex_size=vertex_size,
vertex_color=vertex_color,
edge_arrow_size=edge_arrow_size,
vertex_frame_width=vertex_frame_width,
bbox=(width, height),
)

View File

@ -0,0 +1,657 @@
# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
# Licensed under the Apache License, Version 2.0 (the “License”);
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an “AS IS” BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
from __future__ import annotations
import ast
import asyncio
import json
from typing import List, Optional, Union
import pandas as pd
import tqdm
from camel.memories import MemoryRecord
from camel.messages import BaseMessage
from camel.models import BaseModelBackend, ModelManager
from camel.types import OpenAIBackendRole
from oasis.social_agent import AgentGraph, SocialAgent
from oasis.social_platform import Channel, Platform
from oasis.social_platform.config import Neo4jConfig, UserInfo
from oasis.social_platform.typing import ActionType
async def generate_agents(
agent_info_path: str,
channel: Channel,
model: Union[BaseModelBackend, List[BaseModelBackend]],
start_time,
recsys_type: str = "twitter",
twitter: Platform = None,
available_actions: list[ActionType] = None,
neo4j_config: Neo4jConfig | None = None,
) -> AgentGraph:
"""TODO: need update the description of args and check
Generate and return a dictionary of agents from the agent
information CSV file. Each agent is added to the database and
their respective profiles are updated.
Args:
agent_info_path (str): The file path to the agent information CSV file.
channel (Channel): Information channel.
action_space_prompt (str): determine the action space of agents.
model_random_seed (int): Random seed to randomly assign model to
each agent. (default: 42)
cfgs (list, optional): List of configuration. (default: `None`)
neo4j_config (Neo4jConfig, optional): Neo4j graph database
configuration. (default: `None`)
Returns:
dict: A dictionary of agent IDs mapped to their respective agent
class instances.
"""
agent_info = pd.read_csv(agent_info_path)
agent_graph = (AgentGraph() if neo4j_config is None else AgentGraph(
backend="neo4j",
neo4j_config=neo4j_config,
))
# agent_graph = []
sign_up_list = []
follow_list = []
user_update1 = []
user_update2 = []
post_list = []
for agent_id in range(len(agent_info)):
profile = {
"nodes": [],
"edges": [],
"other_info": {},
}
profile["other_info"]["user_profile"] = agent_info["user_char"][
agent_id]
user_info = UserInfo(
name=agent_info["username"][agent_id],
description=agent_info["description"][agent_id],
profile=profile,
recsys_type=recsys_type,
)
agent = SocialAgent(
agent_id=agent_id,
user_info=user_info,
channel=channel,
model=model,
agent_graph=agent_graph,
available_actions=available_actions,
)
agent_graph.add_agent(agent)
# TODO we should not use following_count and followers_count
# We should calculate the number of followings and followers
# based on the graph because the following situation is dynamic.
num_followings = 0
num_followers = 0
sign_up_list.append((
agent_id,
agent_id,
agent_info["username"][agent_id],
agent_info["name"][agent_id],
agent_info["description"][agent_id],
start_time,
num_followings,
num_followers,
))
following_id_list = ast.literal_eval(
agent_info["following_agentid_list"][agent_id])
if not isinstance(following_id_list, int):
if len(following_id_list) != 0:
for follow_id in following_id_list:
follow_list.append((agent_id, follow_id, start_time))
user_update1.append((agent_id, ))
user_update2.append((follow_id, ))
agent_graph.add_edge(agent_id, follow_id)
previous_posts = ast.literal_eval(
agent_info["previous_tweets"][agent_id])
if len(previous_posts) != 0:
for post in previous_posts:
post_list.append((agent_id, post, start_time, 0, 0))
# generate_log.info('agent gegenerate finished.')
user_insert_query = (
"INSERT INTO user (user_id, agent_id, user_name, name, bio, "
"created_at, num_followings, num_followers) VALUES "
"(?, ?, ?, ?, ?, ?, ?, ?)")
twitter.pl_utils._execute_many_db_command(user_insert_query,
sign_up_list,
commit=True)
follow_insert_query = (
"INSERT INTO follow (follower_id, followee_id, created_at) "
"VALUES (?, ?, ?)")
twitter.pl_utils._execute_many_db_command(follow_insert_query,
follow_list,
commit=True)
user_update_query1 = (
"UPDATE user SET num_followings = num_followings + 1 "
"WHERE user_id = ?")
twitter.pl_utils._execute_many_db_command(user_update_query1,
user_update1,
commit=True)
user_update_query2 = ("UPDATE user SET num_followers = num_followers + 1 "
"WHERE user_id = ?")
twitter.pl_utils._execute_many_db_command(user_update_query2,
user_update2,
commit=True)
# generate_log.info('twitter followee update finished.')
post_insert_query = (
"INSERT INTO post (user_id, content, created_at, num_likes, "
"num_dislikes) VALUES (?, ?, ?, ?, ?)")
twitter.pl_utils._execute_many_db_command(post_insert_query,
post_list,
commit=True)
# generate_log.info('twitter creat post finished.')
return agent_graph
async def generate_agents_100w(
agent_info_path: str,
channel: Channel,
start_time,
model: Union[BaseModelBackend, List[BaseModelBackend]],
recsys_type: str = "twitter",
twitter: Platform = None,
available_actions: list[ActionType] = None,
) -> List:
""" TODO: need update the description of args.
Generate and return a dictionary of agents from the agent
information CSV file. Each agent is added to the database and
their respective profiles are updated.
Args:
agent_info_path (str): The file path to the agent information CSV file.
channel (Channel): Information channel.
action_space_prompt (str): determine the action space of agents.
model_random_seed (int): Random seed to randomly assign model to
each agent. (default: 42)
Returns:
dict: A dictionary of agent IDs mapped to their respective agent
class instances.
"""
agent_info = pd.read_csv(agent_info_path)
# TODO when setting 100w agents, the agentgraph class is too slow.
# I use the list.
agent_graph = []
# agent_graph = (AgentGraph() if neo4j_config is None else AgentGraph(
# backend="neo4j",
# neo4j_config=neo4j_config,
# ))
# agent_graph = []
sign_up_list = []
follow_list = []
user_update1 = []
user_update2 = []
post_list = []
# precompute to speed up agent generation in one million scale
_ = agent_info["following_agentid_list"].apply(ast.literal_eval)
previous_tweets_lists = agent_info["previous_tweets"].apply(
ast.literal_eval)
previous_tweets_lists = agent_info['previous_tweets'].apply(
ast.literal_eval)
following_id_lists = agent_info["following_agentid_list"].apply(
ast.literal_eval)
for agent_id in tqdm.tqdm(range(len(agent_info))):
profile = {
"nodes": [],
"edges": [],
"other_info": {},
}
profile["other_info"]["user_profile"] = agent_info["user_char"][
agent_id]
# TODO if you simulate one million agents, use active threshold below.
# profile['other_info']['active_threshold'] = [0.01] * 24
user_info = UserInfo(
name=agent_info["username"][agent_id],
description=agent_info["description"][agent_id],
profile=profile,
recsys_type=recsys_type,
)
agent = SocialAgent(
agent_id=agent_id,
user_info=user_info,
channel=channel,
model=model,
agent_graph=agent_graph,
available_actions=available_actions,
)
agent_graph.append(agent)
num_followings = 0
num_followers = 0
# print('agent_info["following_count"]', agent_info["following_count"])
# TODO some data does not cotain this key.
if 'following_count' not in agent_info.columns:
agent_info['following_count'] = 0
if 'followers_count' not in agent_info.columns:
agent_info['followers_count'] = 0
if not agent_info["following_count"].empty:
num_followings = agent_info["following_count"][agent_id]
if not agent_info["followers_count"].empty:
num_followers = agent_info["followers_count"][agent_id]
sign_up_list.append((
agent_id,
agent_id,
agent_info["username"][agent_id],
agent_info["name"][agent_id],
agent_info["description"][agent_id],
start_time,
num_followings,
num_followers,
))
following_id_list = following_id_lists[agent_id]
# TODO If we simulate 1 million agents, we can not use agent_graph
# class. It is not scalble.
if not isinstance(following_id_list, int):
if len(following_id_list) != 0:
for follow_id in following_id_list:
follow_list.append((agent_id, follow_id, start_time))
user_update1.append((agent_id, ))
user_update2.append((follow_id, ))
# agent_graph.add_edge(agent_id, follow_id)
previous_posts = previous_tweets_lists[agent_id]
if len(previous_posts) != 0:
for post in previous_posts:
post_list.append((agent_id, post, start_time, 0, 0))
# generate_log.info('agent gegenerate finished.')
user_insert_query = (
"INSERT INTO user (user_id, agent_id, user_name, name, bio, "
"created_at, num_followings, num_followers) VALUES "
"(?, ?, ?, ?, ?, ?, ?, ?)")
twitter.pl_utils._execute_many_db_command(user_insert_query,
sign_up_list,
commit=True)
follow_insert_query = (
"INSERT INTO follow (follower_id, followee_id, created_at) "
"VALUES (?, ?, ?)")
twitter.pl_utils._execute_many_db_command(follow_insert_query,
follow_list,
commit=True)
if not (agent_info["following_count"].empty
and agent_info["followers_count"].empty):
user_update_query1 = (
"UPDATE user SET num_followings = num_followings + 1 "
"WHERE user_id = ?")
twitter.pl_utils._execute_many_db_command(user_update_query1,
user_update1,
commit=True)
user_update_query2 = (
"UPDATE user SET num_followers = num_followers + 1 "
"WHERE user_id = ?")
twitter.pl_utils._execute_many_db_command(user_update_query2,
user_update2,
commit=True)
# generate_log.info('twitter followee update finished.')
post_insert_query = (
"INSERT INTO post (user_id, content, created_at, num_likes, "
"num_dislikes) VALUES (?, ?, ?, ?, ?)")
twitter.pl_utils._execute_many_db_command(post_insert_query,
post_list,
commit=True)
# generate_log.info('twitter creat post finished.')
return agent_graph
async def generate_controllable_agents(
channel: Channel,
control_user_num: int,
) -> tuple[AgentGraph, dict]:
agent_graph = AgentGraph()
agent_user_id_mapping = {}
for i in range(control_user_num):
user_info = UserInfo(
is_controllable=True,
profile={"other_info": {
"user_profile": "None"
}},
recsys_type="reddit",
)
# controllable的agent_id全都在llm agent的agent_id的前面
agent = SocialAgent(agent_id=i,
user_info=user_info,
channel=channel,
agent_graph=agent_graph)
# Add agent to the agent graph
agent_graph.add_agent(agent)
username = input(f"Please input username for agent {i}: ")
name = input(f"Please input name for agent {i}: ")
bio = input(f"Please input bio for agent {i}: ")
response = await agent.env.action.sign_up(username, name, bio)
user_id = response["user_id"]
agent_user_id_mapping[i] = user_id
for i in range(control_user_num):
for j in range(control_user_num):
agent = agent_graph.get_agent(i)
# controllable agent互相也全部关注
if i != j:
user_id = agent_user_id_mapping[j]
await agent.env.action.follow(user_id)
agent_graph.add_edge(i, j)
return agent_graph, agent_user_id_mapping
async def gen_control_agents_with_data(
channel: Channel,
control_user_num: int,
models: list[BaseModelBackend] | None = None,
) -> tuple[AgentGraph, dict]:
agent_graph = AgentGraph()
agent_user_id_mapping = {}
for i in range(control_user_num):
user_info = UserInfo(
is_controllable=True,
profile={
"other_info": {
"user_profile": "None",
"gender": "None",
"mbti": "None",
"country": "None",
"age": "None",
}
},
recsys_type="reddit",
)
# controllable的agent_id全都在llm agent的agent_id的前面
agent = SocialAgent(
agent_id=i,
user_info=user_info,
channel=channel,
agent_graph=agent_graph,
model=models,
available_actions=None,
)
# Add agent to the agent graph
agent_graph.add_agent(agent)
user_name = "momo"
name = "momo"
bio = "None."
response = await agent.env.action.sign_up(user_name, name, bio)
user_id = response["user_id"]
agent_user_id_mapping[i] = user_id
return agent_graph, agent_user_id_mapping
async def generate_reddit_agents(
agent_info_path: str,
channel: Channel,
agent_graph: AgentGraph | None = None,
agent_user_id_mapping: dict[int, int] | None = None,
follow_post_agent: bool = False,
mute_post_agent: bool = False,
model: Optional[Union[BaseModelBackend, List[BaseModelBackend],
ModelManager]] = None,
available_actions: list[ActionType] = None,
) -> AgentGraph:
if agent_user_id_mapping is None:
agent_user_id_mapping = {}
if agent_graph is None:
agent_graph = AgentGraph()
control_user_num = agent_graph.get_num_nodes()
with open(agent_info_path, "r") as file:
agent_info = json.load(file)
async def process_agent(i):
# Instantiate an agent
profile = {
"nodes": [], # Relationships with other agents
"edges": [], # Relationship details
"other_info": {},
}
# Update agent profile with additional information
profile["other_info"]["user_profile"] = agent_info[i]["persona"]
profile["other_info"]["mbti"] = agent_info[i]["mbti"]
profile["other_info"]["gender"] = agent_info[i]["gender"]
profile["other_info"]["age"] = agent_info[i]["age"]
profile["other_info"]["country"] = agent_info[i]["country"]
user_info = UserInfo(
name=agent_info[i]["username"],
description=agent_info[i]["bio"],
profile=profile,
recsys_type="reddit",
)
agent = SocialAgent(
agent_id=i + control_user_num,
user_info=user_info,
channel=channel,
agent_graph=agent_graph,
model=model,
available_actions=available_actions,
)
# Add agent to the agent graph
agent_graph.add_agent(agent)
# Sign up agent and add their information to the database
# print(f"Signing up agent {agent_info['username'][i]}...")
response = await agent.env.action.sign_up(agent_info[i]["username"],
agent_info[i]["realname"],
agent_info[i]["bio"])
user_id = response["user_id"]
agent_user_id_mapping[i + control_user_num] = user_id
if follow_post_agent:
await agent.env.action.follow(1)
content = """
{
"reason": "He is my friend, and I would like to follow him "
"on social media.",
"functions": [
{
"name": "follow",
"arguments": {
"user_id": 1
}
}
]
}
"""
agent_msg = BaseMessage.make_assistant_message(
role_name="Assistant", content=content)
agent.memory.write_record(
MemoryRecord(agent_msg, OpenAIBackendRole.ASSISTANT))
elif mute_post_agent:
await agent.env.action.mute(1)
content = """
{
"reason": "He is my enemy, and I would like to mute him on social media.",
"functions": [{
"name": "mute",
"arguments": {
"user_id": 1
}
}
"""
agent_msg = BaseMessage.make_assistant_message(
role_name="Assistant", content=content)
agent.memory.write_record(
MemoryRecord(agent_msg, OpenAIBackendRole.ASSISTANT))
tasks = [process_agent(i) for i in range(len(agent_info))]
await asyncio.gather(*tasks)
return agent_graph
def connect_platform_channel(
channel: Channel,
agent_graph: AgentGraph | None = None,
) -> AgentGraph:
for _, agent in agent_graph.get_agents():
agent.channel = channel
agent.env.action.channel = channel
return agent_graph
async def generate_custom_agents(
channel: Channel,
agent_graph: AgentGraph | None = None,
) -> AgentGraph:
if agent_graph is None:
agent_graph = AgentGraph()
agent_graph = connect_platform_channel(channel=channel,
agent_graph=agent_graph)
sign_up_tasks = [
agent.env.action.sign_up(user_name=agent.user_info.user_name,
name=agent.user_info.name,
bio=agent.user_info.description)
for _, agent in agent_graph.get_agents()
]
await asyncio.gather(*sign_up_tasks)
return agent_graph
async def generate_reddit_agent_graph(
profile_path: str,
model: Optional[Union[BaseModelBackend, List[BaseModelBackend],
ModelManager]] = None,
available_actions: list[ActionType] = None,
) -> AgentGraph:
agent_graph = AgentGraph()
with open(profile_path, "r") as file:
agent_info = json.load(file)
async def process_agent(i):
# Instantiate an agent
profile = {
"nodes": [], # Relationships with other agents
"edges": [], # Relationship details
"other_info": {},
}
# Update agent profile with additional information.
# Use .get() with sensible defaults so a malformed/missing-field
# profile (which happens when the LLM-emitted persona JSON fails to
# parse and the persona worker falls back to a basic structure) does
# not KeyError the whole simulation at start time. Hard-coding
# defaults here means a sim can run with a few sparse profiles
# instead of crashing on round 1.
profile["other_info"]["user_profile"] = (
agent_info[i].get("persona") or agent_info[i].get("bio", "")
)
profile["other_info"]["mbti"] = agent_info[i].get("mbti", "ISTJ")
profile["other_info"]["gender"] = agent_info[i].get("gender", "other")
profile["other_info"]["age"] = agent_info[i].get("age", 30)
profile["other_info"]["country"] = agent_info[i].get("country", "Unknown")
user_info = UserInfo(
name=agent_info[i]["username"],
description=agent_info[i]["bio"],
profile=profile,
recsys_type="reddit",
)
agent = SocialAgent(
agent_id=i,
user_info=user_info,
agent_graph=agent_graph,
model=model,
available_actions=available_actions,
)
# Add agent to the agent graph
agent_graph.add_agent(agent)
tasks = [process_agent(i) for i in range(len(agent_info))]
await asyncio.gather(*tasks)
return agent_graph
async def generate_twitter_agent_graph(
profile_path: str,
model: Optional[Union[BaseModelBackend, List[BaseModelBackend],
ModelManager]] = None,
available_actions: list[ActionType] = None,
) -> AgentGraph:
agent_info = pd.read_csv(profile_path)
agent_graph = AgentGraph()
for agent_id in range(len(agent_info)):
profile = {
"nodes": [],
"edges": [],
"other_info": {},
}
profile["other_info"]["user_profile"] = agent_info["user_char"][
agent_id]
user_info = UserInfo(
name=agent_info["username"][agent_id],
description=agent_info["description"][agent_id],
profile=profile,
recsys_type='twitter',
)
agent = SocialAgent(
agent_id=agent_id,
user_info=user_info,
model=model,
agent_graph=agent_graph,
available_actions=available_actions,
)
agent_graph.add_agent(agent)
return agent_graph

View File

@ -0,0 +1,20 @@
# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
# Licensed under the Apache License, Version 2.0 (the “License”);
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an “AS IS” BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
from .channel import Channel
from .platform import Platform
__all__ = [
"Channel",
"Platform",
]

View File

@ -0,0 +1,71 @@
# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
# Licensed under the Apache License, Version 2.0 (the “License”);
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an “AS IS” BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
import asyncio
import uuid
class AsyncSafeDict:
def __init__(self):
self.dict = {}
self.lock = asyncio.Lock()
async def put(self, key, value):
async with self.lock:
self.dict[key] = value
async def get(self, key, default=None):
async with self.lock:
return self.dict.get(key, default)
async def pop(self, key, default=None):
async with self.lock:
return self.dict.pop(key, default)
async def keys(self):
async with self.lock:
return list(self.dict.keys())
class Channel:
def __init__(self):
self.receive_queue = asyncio.Queue() # Used to store received messages
# Using an asynchronous safe dictionary to store messages to be sent
self.send_dict = AsyncSafeDict()
async def receive_from(self):
message = await self.receive_queue.get()
return message
async def send_to(self, message):
# message_id is the first element of the message
message_id = message[0]
await self.send_dict.put(message_id, message)
async def write_to_receive_queue(self, action_info):
message_id = str(uuid.uuid4())
await self.receive_queue.put((message_id, action_info))
return message_id
async def read_from_send_queue(self, message_id):
while True:
if message_id in await self.send_dict.keys():
# Attempting to retrieve the message
message = await self.send_dict.pop(message_id, None)
if message:
return message # Return the found message
# Temporarily suspend to avoid tight looping
await asyncio.sleep(
0.1) # set a large one to reduce the workload of cpu

View File

@ -0,0 +1,20 @@
# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
# Licensed under the Apache License, Version 2.0 (the “License”);
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an “AS IS” BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
from .neo4j import Neo4jConfig
from .user import UserInfo
__all__ = [
"UserInfo",
"Neo4jConfig",
]

View File

@ -0,0 +1,24 @@
# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
# Licensed under the Apache License, Version 2.0 (the “License”);
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an “AS IS” BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
from dataclasses import dataclass
@dataclass
class Neo4jConfig:
uri: str | None = None
username: str | None = None
password: str | None = None
def is_valid(self) -> bool:
return all([self.uri, self.username, self.password])

View File

@ -0,0 +1,111 @@
# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
# Licensed under the Apache License, Version 2.0 (the “License”);
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an “AS IS” BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
# flake8: noqa: E501
import warnings
from dataclasses import dataclass
from typing import Any
from camel.prompts import TextPrompt
@dataclass
class UserInfo:
user_name: str | None = None
name: str | None = None
description: str | None = None
profile: dict[str, Any] | None = None
recsys_type: str = "twitter"
is_controllable: bool = False
def to_custom_system_message(self, user_info_template: TextPrompt) -> str:
required_keys = user_info_template.key_words
info_keys = set(self.profile.keys())
missing = required_keys - info_keys
extra = info_keys - required_keys
if missing:
raise ValueError(
f"Missing required keys in UserInfo.profile: {missing}")
if extra:
warnings.warn(f"Extra keys not used in UserInfo.profile: {extra}")
return user_info_template.format(**self.profile)
def to_system_message(self) -> str:
if self.recsys_type != "reddit":
return self.to_twitter_system_message()
else:
return self.to_reddit_system_message()
def to_twitter_system_message(self) -> str:
name_string = ""
description_string = ""
if self.name is not None:
name_string = f"Your name is {self.name}."
if self.profile is None:
description = name_string
elif "other_info" not in self.profile:
description = name_string
elif "user_profile" in self.profile["other_info"]:
if self.profile["other_info"]["user_profile"] is not None:
user_profile = self.profile["other_info"]["user_profile"]
description_string = f"Your have profile: {user_profile}."
description = f"{name_string}\n{description_string}"
system_content = f"""
# OBJECTIVE
You're a Twitter user, and I'll present you with some posts. After you see the posts, choose some actions from the following functions.
# SELF-DESCRIPTION
Your actions should be consistent with your self-description and personality.
{description}
# RESPONSE METHOD
Please perform actions by tool calling.
"""
return system_content
def to_reddit_system_message(self) -> str:
name_string = ""
description_string = ""
if self.name is not None:
name_string = f"Your name is {self.name}."
if self.profile is None:
description = name_string
elif "other_info" not in self.profile:
description = name_string
elif "user_profile" in self.profile["other_info"]:
if self.profile["other_info"]["user_profile"] is not None:
user_profile = self.profile["other_info"]["user_profile"]
description_string = f"Your have profile: {user_profile}."
description = f"{name_string}\n{description_string}"
print(self.profile['other_info'])
description += (
f"You are a {self.profile['other_info']['gender']}, "
f"{self.profile['other_info']['age']} years old, with an MBTI "
f"personality type of {self.profile['other_info']['mbti']} from "
f"{self.profile['other_info']['country']}.")
system_content = f"""
# OBJECTIVE
You're a Reddit user, and I'll present you with some tweets. After you see the tweets, choose some actions from the following functions.
# SELF-DESCRIPTION
Your actions should be consistent with your self-description and personality.
{description}
# RESPONSE METHOD
Please perform actions by tool calling.
"""
return system_content

View File

@ -0,0 +1,291 @@
# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
# Licensed under the Apache License, Version 2.0 (the “License”);
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an “AS IS” BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
from __future__ import annotations
import os
import os.path as osp
import sqlite3
from typing import Any, Dict, List
SCHEMA_DIR = "social_platform/schema"
DB_DIR = "data"
DB_NAME = "social_media.db"
USER_SCHEMA_SQL = "user.sql"
POST_SCHEMA_SQL = "post.sql"
FOLLOW_SCHEMA_SQL = "follow.sql"
MUTE_SCHEMA_SQL = "mute.sql"
LIKE_SCHEMA_SQL = "like.sql"
DISLIKE_SCHEMA_SQL = "dislike.sql"
REPORT_SCHEAM_SQL = "report.sql"
TRACE_SCHEMA_SQL = "trace.sql"
REC_SCHEMA_SQL = "rec.sql"
COMMENT_SCHEMA_SQL = "comment.sql"
COMMENT_LIKE_SCHEMA_SQL = "comment_like.sql"
COMMENT_DISLIKE_SCHEMA_SQL = "comment_dislike.sql"
PRODUCT_SCHEMA_SQL = "product.sql"
GROUP_SCHEMA_SQL = "chat_group.sql"
GROUP_MEMBER_SCHEMA_SQL = "group_member.sql"
GROUP_MESSAGE_SCHEMA_SQL = "group_message.sql"
TABLE_NAMES = {
"user",
"post",
"follow",
"mute",
"like",
"dislike",
"report",
"trace",
"rec",
"comment.sql",
"comment_like.sql",
"comment_dislike.sql",
"product.sql",
"group",
"group_member",
"group_message",
}
def get_db_path() -> str:
# First check if the database path is set in environment variables
env_db_path = os.environ.get("OASIS_DB_PATH")
if env_db_path:
return env_db_path
# If no environment variable is set, use the original default path
curr_file_path = osp.abspath(__file__)
parent_dir = osp.dirname(osp.dirname(curr_file_path))
db_dir = osp.join(parent_dir, DB_DIR)
os.makedirs(db_dir, exist_ok=True)
db_path = osp.join(db_dir, DB_NAME)
return db_path
def get_schema_dir_path() -> str:
curr_file_path = osp.abspath(__file__)
parent_dir = osp.dirname(osp.dirname(curr_file_path))
schema_dir = osp.join(parent_dir, SCHEMA_DIR)
return schema_dir
def create_db(db_path: str | None = None):
r"""Create the database if it does not exist. A :obj:`twitter.db`
file will be automatically created in the :obj:`data` directory.
"""
schema_dir = get_schema_dir_path()
if db_path is None:
db_path = get_db_path()
# Connect to the database:
print("db_path", db_path)
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
try:
# Read and execute the user table SQL script:
user_sql_path = osp.join(schema_dir, USER_SCHEMA_SQL)
with open(user_sql_path, "r") as sql_file:
user_sql_script = sql_file.read()
cursor.executescript(user_sql_script)
# Read and execute the post table SQL script:
post_sql_path = osp.join(schema_dir, POST_SCHEMA_SQL)
with open(post_sql_path, "r") as sql_file:
post_sql_script = sql_file.read()
cursor.executescript(post_sql_script)
# Read and execute the follow table SQL script:
follow_sql_path = osp.join(schema_dir, FOLLOW_SCHEMA_SQL)
with open(follow_sql_path, "r") as sql_file:
follow_sql_script = sql_file.read()
cursor.executescript(follow_sql_script)
# Read and execute the mute table SQL script:
mute_sql_path = osp.join(schema_dir, MUTE_SCHEMA_SQL)
with open(mute_sql_path, "r") as sql_file:
mute_sql_script = sql_file.read()
cursor.executescript(mute_sql_script)
# Read and execute the like table SQL script:
like_sql_path = osp.join(schema_dir, LIKE_SCHEMA_SQL)
with open(like_sql_path, "r") as sql_file:
like_sql_script = sql_file.read()
cursor.executescript(like_sql_script)
# Read and execute the dislike table SQL script:
dislike_sql_path = osp.join(schema_dir, DISLIKE_SCHEMA_SQL)
with open(dislike_sql_path, "r") as sql_file:
dislike_sql_script = sql_file.read()
cursor.executescript(dislike_sql_script)
# Read and execute the report table SQL script:
report_sql_path = osp.join(schema_dir, REPORT_SCHEAM_SQL)
with open(report_sql_path, "r") as sql_file:
report_sql_script = sql_file.read()
cursor.executescript(report_sql_script)
# Read and execute the trace table SQL script:
trace_sql_path = osp.join(schema_dir, TRACE_SCHEMA_SQL)
with open(trace_sql_path, "r") as sql_file:
trace_sql_script = sql_file.read()
cursor.executescript(trace_sql_script)
# Read and execute the rec table SQL script:
rec_sql_path = osp.join(schema_dir, REC_SCHEMA_SQL)
with open(rec_sql_path, "r") as sql_file:
rec_sql_script = sql_file.read()
cursor.executescript(rec_sql_script)
# Read and execute the comment table SQL script:
comment_sql_path = osp.join(schema_dir, COMMENT_SCHEMA_SQL)
with open(comment_sql_path, "r") as sql_file:
comment_sql_script = sql_file.read()
cursor.executescript(comment_sql_script)
# Read and execute the comment_like table SQL script:
comment_like_sql_path = osp.join(schema_dir, COMMENT_LIKE_SCHEMA_SQL)
with open(comment_like_sql_path, "r") as sql_file:
comment_like_sql_script = sql_file.read()
cursor.executescript(comment_like_sql_script)
# Read and execute the comment_dislike table SQL script:
comment_dislike_sql_path = osp.join(schema_dir,
COMMENT_DISLIKE_SCHEMA_SQL)
with open(comment_dislike_sql_path, "r") as sql_file:
comment_dislike_sql_script = sql_file.read()
cursor.executescript(comment_dislike_sql_script)
# Read and execute the product table SQL script:
product_sql_path = osp.join(schema_dir, PRODUCT_SCHEMA_SQL)
with open(product_sql_path, "r") as sql_file:
product_sql_script = sql_file.read()
cursor.executescript(product_sql_script)
# Read and execute the group table SQL script:
group_sql_path = osp.join(schema_dir, GROUP_SCHEMA_SQL)
with open(group_sql_path, "r") as sql_file:
group_sql_script = sql_file.read()
cursor.executescript(group_sql_script)
# Read and execute the group_member table SQL script:
group_member_sql_path = osp.join(schema_dir, GROUP_MEMBER_SCHEMA_SQL)
with open(group_member_sql_path, "r") as sql_file:
group_member_sql_script = sql_file.read()
cursor.executescript(group_member_sql_script)
# Read and execute the group_message table SQL script:
group_message_sql_path = osp.join(schema_dir, GROUP_MESSAGE_SCHEMA_SQL)
with open(group_message_sql_path, "r") as sql_file:
group_message_sql_script = sql_file.read()
cursor.executescript(group_message_sql_script)
# Commit the changes:
conn.commit()
except sqlite3.Error as e:
print(f"An error occurred while creating tables: {e}")
return conn, cursor
def print_db_tables_summary():
# Connect to the SQLite database
db_path = get_db_path()
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
# Retrieve a list of all tables in the database
cursor.execute("SELECT name FROM sqlite_master WHERE type='table';")
tables = cursor.fetchall()
# Print a summary of each table
for table in tables:
table_name = table[0]
if table_name not in TABLE_NAMES:
continue
print(f"Table: {table_name}")
# Retrieve the table schema
cursor.execute(f"PRAGMA table_info({table_name})")
columns = cursor.fetchall()
column_names = [column[1] for column in columns]
print("- Columns:", column_names)
# Retrieve and print foreign key information
cursor.execute(f"PRAGMA foreign_key_list({table_name})")
foreign_keys = cursor.fetchall()
if foreign_keys:
print("- Foreign Keys:")
for fk in foreign_keys:
print(f" {fk[2]} references {fk[3]}({fk[4]}) on update "
f"{fk[5]} on delete {fk[6]}")
else:
print(" No foreign keys.")
# Print the first few rows of the table
cursor.execute(f"SELECT * FROM {table_name} LIMIT 5;")
rows = cursor.fetchall()
for row in rows:
print(row)
print() # Adds a newline for better readability between tables
# Close the database connection
conn.close()
def fetch_table_from_db(cursor: sqlite3.Cursor,
table_name: str) -> List[Dict[str, Any]]:
cursor.execute(f"SELECT * FROM {table_name}")
columns = [description[0] for description in cursor.description]
data_dicts = [dict(zip(columns, row)) for row in cursor.fetchall()]
return data_dicts
def fetch_rec_table_as_matrix(cursor: sqlite3.Cursor) -> List[List[int]]:
# First, query all user_ids from the user table, assuming they start from
# 1 and are consecutive
cursor.execute("SELECT user_id FROM user ORDER BY user_id")
user_ids = [row[0] for row in cursor.fetchall()]
# Then, query all records from the rec table
cursor.execute(
"SELECT user_id, post_id FROM rec ORDER BY user_id, post_id")
rec_rows = cursor.fetchall()
# Initialize a dictionary, assigning an empty list to each user_id
user_posts = {user_id: [] for user_id in user_ids}
# Fill the dictionary with the records queried from the rec table
for user_id, post_id in rec_rows:
if user_id in user_posts:
user_posts[user_id].append(post_id)
# Convert the dictionary into matrix form
matrix = [user_posts[user_id] for user_id in user_ids]
return matrix
def insert_matrix_into_rec_table(cursor: sqlite3.Cursor,
matrix: List[List[int]]) -> None:
# Iterate through the matrix, skipping the placeholder at index 0
for user_id, post_ids in enumerate(matrix, start=1):
# Adjusted to start counting from 1
for post_id in post_ids:
# Insert each combination of user_id and post_id into the rec table
cursor.execute("INSERT INTO rec (user_id, post_id) VALUES (?, ?)",
(user_id, post_id))
if __name__ == "__main__":
create_db()
print_db_tables_summary()

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,262 @@
# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
# Licensed under the Apache License, Version 2.0 (the “License”);
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an “AS IS” BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
import json
from datetime import datetime
from oasis.social_platform.typing import RecsysType
class PlatformUtils:
def __init__(self,
db,
db_cursor,
start_time,
sandbox_clock,
show_score,
recsys_type,
report_threshold=1):
self.db = db
self.db_cursor = db_cursor
self.start_time = start_time
self.sandbox_clock = sandbox_clock
self.show_score = show_score
self.recsys_type = recsys_type
self.report_threshold = report_threshold
@staticmethod
def _not_signup_error_message(agent_id):
return {
"success":
False,
"error": (f"Agent {agent_id} has not signed up and does not have "
f"a user id."),
}
def _execute_db_command(self, command, args=(), commit=False):
self.db_cursor.execute(command, args)
if commit:
self.db.commit()
return self.db_cursor
def _execute_many_db_command(self, command, args_list, commit=False):
self.db_cursor.executemany(command, args_list)
if commit:
self.db.commit()
return self.db_cursor
def _check_agent_userid(self, agent_id):
try:
user_query = "SELECT user_id FROM user WHERE agent_id = ?"
results = self._execute_db_command(user_query, (agent_id, ))
# Fetch the first row of the query result
first_row = results.fetchone()
if first_row:
user_id = first_row[0]
return user_id
else:
return None
except Exception as e:
# Log or handle the error as appropriate
print(f"Error querying user_id for agent_id {agent_id}: {e}")
return None
def _add_comments_to_posts(self, posts_results):
# Initialize the returned posts list
posts = []
for row in posts_results:
(post_id, user_id, original_post_id, content, quote_content,
created_at, num_likes, num_dislikes, num_shares) = row
post_type_result = self._get_post_type(post_id)
if post_type_result is None:
continue
original_user_id_query = (
"SELECT user_id FROM post WHERE post_id = ?")
if post_type_result["type"] == "repost":
self.db_cursor.execute(original_user_id_query,
(original_post_id, ))
original_user_id = self.db_cursor.fetchone()[0]
original_post_id = post_id
post_id = post_type_result["root_post_id"]
self.db_cursor.execute(
"SELECT content, quote_content, created_at, num_likes, "
"num_dislikes, num_shares, num_reports FROM post "
"WHERE post_id = ?", (post_id, ))
original_post_result = self.db_cursor.fetchone()
(content, quote_content, created_at, num_likes, num_dislikes,
num_shares, num_reports) = original_post_result
post_content = (
f"User {user_id} reposted a post from User "
f"{original_user_id}. Repost content: {content}. ")
elif post_type_result["type"] == "quote":
self.db_cursor.execute(original_user_id_query,
(original_post_id, ))
original_user_id = self.db_cursor.fetchone()[0]
post_content = (
f"User {user_id} quoted a post from User "
f"{original_user_id}. Quote content: {quote_content}. "
f"Original Content: {content}")
elif post_type_result["type"] == "common":
post_content = content
# Get num_reports for common posts
self.db_cursor.execute(
"SELECT num_reports FROM post WHERE post_id = ?",
(post_id, ))
num_reports = self.db_cursor.fetchone()[0]
# For each post, query its corresponding comments
self.db_cursor.execute(
"SELECT comment_id, post_id, user_id, content, created_at, "
"num_likes, num_dislikes FROM comment WHERE post_id = ?",
(post_id, ),
)
comments_results = self.db_cursor.fetchall()
# Convert each comment's result into dictionary format
comments = [{
"comment_id":
comment_id,
"post_id":
post_id,
"user_id":
user_id,
"content":
content,
"created_at":
created_at,
**({
"score": num_likes - num_dislikes
} if self.show_score else {
"num_likes": num_likes,
"num_dislikes": num_dislikes
}),
} for (
comment_id,
post_id,
user_id,
content,
created_at,
num_likes,
num_dislikes,
) in comments_results]
# Add warning message if the post has been reported
if num_reports >= self.report_threshold:
warning_message = ("[Warning: This post has been reported"
f" {num_reports} times]")
post_content = f"{warning_message}\n{post_content}"
# Add post information and corresponding comments to the posts list
posts.append({
"post_id":
post_id
if post_type_result["type"] != "repost" else original_post_id,
"user_id":
user_id,
"content":
post_content,
"created_at":
created_at,
**({
"score": num_likes - num_dislikes
} if self.show_score else {
"num_likes": num_likes,
"num_dislikes": num_dislikes
}),
"num_shares":
num_shares,
"num_reports":
num_reports,
"comments":
comments,
})
return posts
def _record_trace(self,
user_id,
action_type,
action_info,
current_time=None):
r"""If, in addition to the trace, the operation function also records
time in other tables of the database, use the time of entering
the operation function for consistency.
Pass in current_time to make, for example, the created_at in the post
table exactly the same as the time in the trace table.
If only the trace table needs to record time, use the entry time into
_record_trace as the time for the trace record.
"""
if self.recsys_type == RecsysType.REDDIT:
current_time = self.sandbox_clock.time_transfer(
datetime.now(), self.start_time)
else:
current_time = self.sandbox_clock.get_time_step()
trace_insert_query = (
"INSERT INTO trace (user_id, created_at, action, info) "
"VALUES (?, ?, ?, ?)")
action_info_str = json.dumps(action_info)
self._execute_db_command(
trace_insert_query,
(user_id, current_time, action_type, action_info_str),
commit=True,
)
def _check_self_post_rating(self, post_id, user_id):
self_like_check_query = "SELECT user_id FROM post WHERE post_id = ?"
self._execute_db_command(self_like_check_query, (post_id, ))
result = self.db_cursor.fetchone()
if result and result[0] == user_id:
error_message = ("Users are not allowed to like/dislike their own "
"posts.")
return {"success": False, "error": error_message}
else:
return None
def _check_self_comment_rating(self, comment_id, user_id):
self_like_check_query = ("SELECT user_id FROM comment WHERE "
"comment_id = ?")
self._execute_db_command(self_like_check_query, (comment_id, ))
result = self.db_cursor.fetchone()
if result and result[0] == user_id:
error_message = ("Users are not allowed to like/dislike their "
"own comments.")
return {"success": False, "error": error_message}
else:
return None
def _get_post_type(self, post_id: int):
query = (
"SELECT original_post_id, quote_content FROM post WHERE post_id "
"= ?")
self._execute_db_command(query, (post_id, ))
result = self.db_cursor.fetchone()
if not result:
return None
original_post_id, quote_content = result
if original_post_id is None:
# common post without quote or repost
return {"type": "common", "root_post_id": None}
elif quote_content is None:
# post with repost
return {"type": "repost", "root_post_id": original_post_id}
else:
# post with quote
return {"type": "quote", "root_post_id": original_post_id}

View File

@ -0,0 +1,81 @@
# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
# Licensed under the Apache License, Version 2.0 (the “License”);
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an “AS IS” BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
from typing import List
import torch
from camel.embeddings import OpenAIEmbedding
from camel.types import EmbeddingModelType
from transformers import AutoModel, AutoTokenizer
# Function: Process each batch
@torch.no_grad()
def process_batch(model: AutoModel, tokenizer: AutoTokenizer,
batch_texts: List[str]):
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
inputs = tokenizer(batch_texts,
return_tensors="pt",
padding=True,
truncation=True)
inputs = {key: value.to(device) for key, value in inputs.items()}
outputs = model(**inputs)
return outputs.pooler_output
def generate_post_vector(model: AutoModel, tokenizer: AutoTokenizer, texts,
batch_size):
# Loop through all messages
# If the list of messages is too large, process them in batches.
all_outputs = []
for i in range(0, len(texts), batch_size):
batch_texts = texts[i:i + batch_size]
batch_outputs = process_batch(model, tokenizer, batch_texts)
all_outputs.append(batch_outputs)
all_outputs_tensor = torch.cat(all_outputs, dim=0) # num_posts x dimension
return all_outputs_tensor.cpu()
def generate_post_vector_openai(texts: List[str], batch_size: int = 100):
"""
Generate embeddings using OpenAI API
Args:
texts: List of texts to process
batch_size: Size of each batch
"""
openai_embedding = OpenAIEmbedding(
model_type=EmbeddingModelType.TEXT_EMBEDDING_3_SMALL)
all_embeddings = []
for i in range(0, len(texts), batch_size):
batch_texts = texts[i:i + batch_size]
cleaned_texts = [
text.strip() if text and isinstance(text, str) else "empty"
for text in batch_texts
]
batch_embeddings = openai_embedding.embed_list(objs=cleaned_texts)
batch_tensor = torch.tensor(batch_embeddings)
all_embeddings.append(batch_tensor)
return torch.cat(all_embeddings, dim=0)
if __name__ == "__main__":
# Input list of strings (assuming there are tens of thousands of messages)
# Here, the same message is repeated 10000 times as an example
texts = ["I'm using TwHIN-BERT! #TwHIN-BERT #NLP"] * 10000
# Define batch size
batch_size = 100
all_outputs_tensor = generate_post_vector(texts, batch_size)
print(all_outputs_tensor.shape)

View File

@ -0,0 +1,797 @@
# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
# Licensed under the Apache License, Version 2.0 (the “License”);
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an “AS IS” BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
'''Note that you need to check if it exceeds max_rec_post_len when writing
into rec_matrix'''
import heapq
import logging
import random
import time
from ast import literal_eval
from datetime import datetime
from math import log
from typing import Any, Dict, List
import numpy as np
import torch
from sentence_transformers import SentenceTransformer
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity
from .process_recsys_posts import (generate_post_vector,
generate_post_vector_openai)
from .typing import ActionType, RecsysType
rec_log = logging.getLogger(name='social.rec')
rec_log.setLevel('DEBUG')
# Initially set to None, to be assigned once again in the recsys function
model = None
twhin_tokenizer = None
twhin_model = None
# Create the TF-IDF model
tfidf_vectorizer = TfidfVectorizer()
# Prepare the twhin model
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
# All historical tweets and the most recent tweet of each user
user_previous_post_all = {}
user_previous_post = {}
user_profiles = []
# Get the {post_id: content} dict
t_items = {}
# Get the {uid: follower_count} dict
# It's necessary to ensure that agent registration is sequential, with the
# relationship of user_id=agent_id+1; disorder in registration will cause
# issues here
u_items = {}
# Get the creation times of all tweets, assigning scores based on how recent
# they are
date_score = []
def get_twhin_tokenizer():
global twhin_tokenizer
if twhin_tokenizer is None:
from transformers import AutoTokenizer
twhin_tokenizer = AutoTokenizer.from_pretrained(
pretrained_model_name_or_path="Twitter/twhin-bert-base",
model_max_length=512)
return twhin_tokenizer
def get_twhin_model(device):
global twhin_model
if twhin_model is None:
from transformers import AutoModel
twhin_model = AutoModel.from_pretrained(
pretrained_model_name_or_path="Twitter/twhin-bert-base").to(device)
return twhin_model
def load_model(model_name):
try:
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
if model_name == 'paraphrase-MiniLM-L6-v2':
return SentenceTransformer(model_name,
device=device,
cache_folder="./models")
elif model_name == 'Twitter/twhin-bert-base':
twhin_tokenizer = get_twhin_tokenizer()
twhin_model = get_twhin_model(device)
return twhin_tokenizer, twhin_model
else:
raise ValueError(f"Unknown model name: {model_name}")
except Exception as e:
raise Exception(f"Failed to load the model: {model_name}") from e
def get_recsys_model(recsys_type: str = None):
if recsys_type == RecsysType.TWITTER.value:
model = load_model('paraphrase-MiniLM-L6-v2')
return model
elif recsys_type == RecsysType.TWHIN.value:
twhin_tokenizer, twhin_model = load_model("Twitter/twhin-bert-base")
models = (twhin_tokenizer, twhin_model)
return models
elif (recsys_type == RecsysType.REDDIT.value
or recsys_type == RecsysType.RANDOM.value):
return None
else:
raise ValueError(f"Unknown recsys type: {recsys_type}")
# Move model to GPU if available
device = 'cuda' if torch.cuda.is_available() else 'cpu'
if model is not None:
model.to(device)
else:
pass
# Reset global variables
def reset_globals():
global user_previous_post_all, user_previous_post
global user_profiles, t_items, u_items
global date_score
user_previous_post_all = {}
user_previous_post = {}
user_profiles = []
t_items = {}
u_items = {}
date_score = []
def rec_sys_random(post_table: List[Dict[str, Any]], rec_matrix: List[List],
max_rec_post_len: int) -> List[List]:
"""
Randomly recommend posts to users.
Args:
user_table (List[Dict[str, Any]]): List of users.
post_table (List[Dict[str, Any]]): List of posts.
trace_table (List[Dict[str, Any]]): List of user interactions.
rec_matrix (List[List]): Existing recommendation matrix.
max_rec_post_len (int): Maximum number of recommended posts.
Returns:
List[List]: Updated recommendation matrix.
"""
# Get all post IDs
post_ids = [post['post_id'] for post in post_table]
new_rec_matrix = []
if len(post_ids) <= max_rec_post_len:
# If the number of posts is less than or equal to the maximum number
# of recommendations, each user gets all post IDs
new_rec_matrix = [post_ids] * len(rec_matrix)
else:
# If the number of posts is greater than the maximum number of
# recommendations, each user randomly gets a specified number of post
# IDs
for _ in range(len(rec_matrix)):
new_rec_matrix.append(random.sample(post_ids, max_rec_post_len))
return new_rec_matrix
def calculate_hot_score(num_likes: int, num_dislikes: int,
created_at: datetime) -> int:
"""
Compute the hot score for a post.
Args:
num_likes (int): Number of likes.
num_dislikes (int): Number of dislikes.
created_at (datetime): Creation time of the post.
Returns:
int: Hot score of the post.
Reference:
https://medium.com/hacking-and-gonzo/how-reddit-ranking-algorithms-work-ef111e33d0d9
"""
s = num_likes - num_dislikes
order = log(max(abs(s), 1), 10)
sign = 1 if s > 0 else -1 if s < 0 else 0
# epoch_seconds
epoch = datetime(1970, 1, 1)
td = created_at - epoch
epoch_seconds_result = td.days * 86400 + td.seconds + (
float(td.microseconds) / 1e6)
seconds = epoch_seconds_result - 1134028003
return round(sign * order + seconds / 45000, 7)
def get_recommendations(
user_index,
cosine_similarities,
items,
score,
top_n=100,
):
similarities = np.array(cosine_similarities[user_index])
similarities = similarities * score
top_item_indices = similarities.argsort()[::-1][:top_n]
recommended_items = [(list(items.keys())[i], similarities[i])
for i in top_item_indices]
return recommended_items
def rec_sys_reddit(post_table: List[Dict[str, Any]], rec_matrix: List[List],
max_rec_post_len: int) -> List[List]:
"""
Recommend posts based on Reddit-like hot score.
Args:
post_table (List[Dict[str, Any]]): List of posts.
rec_matrix (List[List]): Existing recommendation matrix.
max_rec_post_len (int): Maximum number of recommended posts.
Returns:
List[List]: Updated recommendation matrix.
"""
# Get all post IDs
post_ids = [post['post_id'] for post in post_table]
if len(post_ids) <= max_rec_post_len:
# If the number of posts is less than or equal to the maximum number
# of recommendations, each user gets all post IDs
new_rec_matrix = [post_ids] * len(rec_matrix)
else:
# The time complexity of this recommendation system is
# O(post_num * log max_rec_post_len)
all_hot_score = []
for post in post_table:
try:
created_at_dt = datetime.strptime(post['created_at'],
"%Y-%m-%d %H:%M:%S.%f")
except Exception:
created_at_dt = datetime.strptime(post['created_at'],
"%Y-%m-%d %H:%M:%S")
hot_score = calculate_hot_score(post['num_likes'],
post['num_dislikes'],
created_at_dt)
all_hot_score.append((hot_score, post['post_id']))
# Sort
top_posts = heapq.nlargest(max_rec_post_len,
all_hot_score,
key=lambda x: x[0])
top_post_ids = [post_id for _, post_id in top_posts]
# If the number of posts is greater than the maximum number of
# recommendations, each user gets a specified number of post IDs
# randomly
new_rec_matrix = [top_post_ids] * len(rec_matrix)
return new_rec_matrix
def rec_sys_personalized(user_table: List[Dict[str, Any]],
post_table: List[Dict[str, Any]],
trace_table: List[Dict[str,
Any]], rec_matrix: List[List],
max_rec_post_len: int) -> List[List]:
"""
Recommend posts based on personalized similarity scores.
Args:
user_table (List[Dict[str, Any]]): List of users.
post_table (List[Dict[str, Any]]): List of posts.
trace_table (List[Dict[str, Any]]): List of user interactions.
rec_matrix (List[List]): Existing recommendation matrix.
max_rec_post_len (int): Maximum number of recommended posts.
Returns:
List[List]: Updated recommendation matrix.
"""
global model
if model is None or isinstance(model, tuple):
model = get_recsys_model(recsys_type="twitter")
post_ids = [post['post_id'] for post in post_table]
print(
f'Running personalized recommendation for {len(user_table)} users...')
start_time = time.time()
new_rec_matrix = []
if len(post_ids) <= max_rec_post_len:
# If the number of posts is less than or equal to the maximum
# recommended length, each user gets all post IDs
new_rec_matrix = [post_ids] * len(rec_matrix)
else:
# If the number of posts is greater than the maximum recommended
# length, each user gets personalized post IDs
user_bios = [
user['bio'] if 'bio' in user and user['bio'] is not None else ''
for user in user_table
]
post_contents = [post['content'] for post in post_table]
if model:
user_embeddings = model.encode(user_bios,
convert_to_tensor=True,
device=device)
post_embeddings = model.encode(post_contents,
convert_to_tensor=True,
device=device)
# Compute dot product similarity
dot_product = torch.matmul(user_embeddings, post_embeddings.T)
# Compute norm
user_norms = torch.norm(user_embeddings, dim=1)
post_norms = torch.norm(post_embeddings, dim=1)
# Compute cosine similarity
similarities = dot_product / (user_norms[:, None] *
post_norms[None, :])
else:
# Generate random similarities
similarities = torch.rand(len(user_table), len(post_table))
# Iterate through each user to generate personalized recommendations.
for user_index, user in enumerate(user_table):
# Filter out posts made by the current user.
filtered_post_indices = [
i for i, post in enumerate(post_table)
if post['user_id'] != user['user_id']
]
user_similarities = similarities[user_index, filtered_post_indices]
# Get the corresponding post IDs for the filtered posts.
filtered_post_ids = [
post_table[i]['post_id'] for i in filtered_post_indices
]
# Determine the top posts based on the similarities, limited by
# max_rec_post_len.
_, top_indices = torch.topk(user_similarities,
k=min(max_rec_post_len,
len(filtered_post_ids)))
top_post_ids = [filtered_post_ids[i] for i in top_indices.tolist()]
# Append the top post IDs to the new recommendation matrix.
new_rec_matrix.append(top_post_ids)
end_time = time.time()
print(f'Personalized recommendation time: {end_time - start_time:.6f}s')
return new_rec_matrix
def get_like_post_id(user_id, action, trace_table):
"""
Get the post IDs that a user has liked or unliked.
Args:
user_id (str): ID of the user.
action (str): Type of action (like or unlike).
post_table (list): List of posts.
trace_table (list): List of user interactions.
Returns:
list: List of post IDs.
"""
# Get post IDs from trace table for the given user and action
trace_post_ids = [
literal_eval(trace['info'])["post_id"] for trace in trace_table
if (trace['user_id'] == user_id and trace['action'] == action)
]
"""Only take the last 5 liked posts, if not enough, pad with the most
recently liked post. Only take IDs, not content, because calculating
embeddings for all posts again is very time-consuming, especially when the
number of agents is large"""
if len(trace_post_ids) < 5 and len(trace_post_ids) > 0:
trace_post_ids += [trace_post_ids[-1]] * (5 - len(trace_post_ids))
elif len(trace_post_ids) > 5:
trace_post_ids = trace_post_ids[-5:]
else:
trace_post_ids = [0]
return trace_post_ids
# Calculate the average cosine similarity between liked posts and target posts
def calculate_like_similarity(liked_vectors, target_vectors):
# Calculate the norms of the vectors
liked_norms = np.linalg.norm(liked_vectors, axis=1)
target_norms = np.linalg.norm(target_vectors, axis=1)
# Calculate dot products
dot_products = np.dot(target_vectors, liked_vectors.T)
# Calculate cosine similarities
cosine_similarities = dot_products / np.outer(target_norms, liked_norms)
# Take the average
average_similarities = np.mean(cosine_similarities, axis=1)
return average_similarities
def coarse_filtering(input_list, scale):
"""
Coarse filtering posts and return selected elements with their indices.
"""
if len(input_list) <= scale:
# Return elements and their indices as list of tuples (element, index)
sampled_indices = range(len(input_list))
return (input_list, sampled_indices)
else:
# Get random sample of scale elements
sampled_indices = random.sample(range(len(input_list)), scale)
sampled_elements = [input_list[idx] for idx in sampled_indices]
# return [(input_list[idx], idx) for idx in sampled_indices]
return (sampled_elements, sampled_indices)
def rec_sys_personalized_twh(
user_table: List[Dict[str, Any]],
post_table: List[Dict[str, Any]],
latest_post_count: int,
trace_table: List[Dict[str, Any]],
rec_matrix: List[List],
max_rec_post_len: int,
current_time: int,
# source_post_indexs: List[int],
recall_only: bool = False,
enable_like_score: bool = False,
use_openai_embedding: bool = False) -> List[List]:
global twhin_model, twhin_tokenizer
if twhin_model is None or twhin_tokenizer is None:
twhin_tokenizer, twhin_model = get_recsys_model(
recsys_type="twhin-bert")
# Set some global variables to reduce time consumption
global date_score, t_items, u_items, user_previous_post
global user_previous_post_all, user_profiles
# Get the uid: follower_count dict
# Update only once, unless adding the feature to include new users midway.
if (not u_items) or len(u_items) != len(user_table):
u_items = {
user['user_id']: user["num_followers"]
for user in user_table
}
if not user_previous_post_all or len(user_previous_post_all) != len(
user_table):
# Each user must have a list of historical tweets
user_previous_post_all = {
index: []
for index in range(len(user_table))
}
user_previous_post = {index: "" for index in range(len(user_table))}
if not user_profiles or len(user_profiles) != len(user_table):
for user in user_table:
if user['bio'] is None:
user_profiles.append('This user does not have profile')
else:
user_profiles.append(user['bio'])
if len(t_items) < len(post_table):
for post in post_table[-latest_post_count:]:
# Get the {post_id: content} dict, update only the latest tweets
t_items[post['post_id']] = post['content']
# Update the user's historical tweets
user_previous_post_all[post['user_id']].append(post['content'])
user_previous_post[post['user_id']] = post['content']
# Get the creation times of all tweets, assigning scores based on
# how recent they are, note that this algorithm can run for a
# maximum of 90 time steps
date_score.append(
np.log(
(271.8 - (current_time - int(post['created_at']))) / 100))
date_score_np = np.array(date_score)
if enable_like_score:
# Calculate similarity with previously liked content, first gather
# liked post ids from the trace
like_post_ids_all = []
for user in user_table:
user_id = user['agent_id']
like_post_ids = get_like_post_id(user_id,
ActionType.LIKE_POST.value,
trace_table)
like_post_ids_all.append(like_post_ids)
scores = date_score_np
new_rec_matrix = []
if len(post_table) <= max_rec_post_len:
# If the number of tweets is less than or equal to the max
# recommendation count, each user gets all post IDs
tids = [t['post_id'] for t in post_table]
new_rec_matrix = [tids] * (len(rec_matrix))
else:
# If the number of tweets is greater than the max recommendation
# count, each user randomly gets personalized post IDs
# This requires going through all users to update their profiles,
# which is a time-consuming operation
for post_user_index in user_previous_post:
try:
# Directly replacing the profile with the latest tweet will
# cause the recommendation system to repeatedly push other
# reposts to users who have already shared that tweet
# user_profiles[post_user_index] =
# user_previous_post[post_user_index]
# Instead, append the description of the Recent post's content
# to the end of the user char
update_profile = (
f" # Recent post:{user_previous_post[post_user_index]}")
if user_previous_post[post_user_index] != "":
# If there's no update for the recent post, add this part
if "# Recent post:" not in user_profiles[post_user_index]:
user_profiles[post_user_index] += update_profile
# If the profile has a recent post but it's not the user's
# latest, replace it
elif update_profile not in user_profiles[post_user_index]:
user_profiles[post_user_index] = user_profiles[
post_user_index].split(
"# Recent post:")[0] + update_profile
except Exception:
print("update previous post failed")
# coarse filtering 4000 posts due to the memory constraint.
filtered_posts_tuple = coarse_filtering(list(t_items.values()), 4000)
corpus = user_profiles + filtered_posts_tuple[0]
# corpus = user_profiles + list(t_items.values())
tweet_vector_start_t = time.time()
if use_openai_embedding:
all_post_vector_list = generate_post_vector_openai(corpus,
batch_size=1000)
else:
all_post_vector_list = generate_post_vector(twhin_model,
twhin_tokenizer,
corpus,
batch_size=1000)
tweet_vector_end_t = time.time()
rec_log.info(
f"twhin model cost time: {tweet_vector_end_t-tweet_vector_start_t}"
)
user_vector = all_post_vector_list[:len(user_profiles)]
posts_vector = all_post_vector_list[len(user_profiles):]
if enable_like_score:
# Traverse all liked post ids, collecting liked post vectors from
# posts_vector for matrix acceleration calculation
like_posts_vectors = []
for user_idx, like_post_ids in enumerate(like_post_ids_all):
if len(like_post_ids) != 1:
for like_post_id in like_post_ids:
try:
like_posts_vectors.append(
posts_vector[like_post_id - 1])
except Exception:
like_posts_vectors.append(user_vector[user_idx])
else:
like_posts_vectors += [
user_vector[user_idx] for _ in range(5)
]
try:
like_posts_vectors = torch.stack(like_posts_vectors).view(
len(user_table), 5, posts_vector.shape[1])
except Exception:
import pdb # noqa: F811
pdb.set_trace()
get_similar_start_t = time.time()
cosine_similarities = cosine_similarity(user_vector, posts_vector)
get_similar_end_t = time.time()
rec_log.info(f"get cosine_similarity time: "
f"{get_similar_end_t-get_similar_start_t}")
if enable_like_score:
for user_index, profile in enumerate(user_profiles):
user_like_posts_vector = like_posts_vectors[user_index]
like_scores = calculate_like_similarity(
user_like_posts_vector, posts_vector)
try:
scores = scores + like_scores
except Exception:
import pdb
pdb.set_trace()
filter_posts_index = filtered_posts_tuple[1]
cosine_similarities = cosine_similarities * scores[filter_posts_index]
cosine_similarities = torch.tensor(cosine_similarities)
value, indices = torch.topk(cosine_similarities,
max_rec_post_len,
dim=1,
largest=True,
sorted=True)
filter_posts_index = torch.tensor(filter_posts_index)
indices = filter_posts_index[indices]
# cosine_similarities = cosine_similarities * scores
# cosine_similarities = torch.tensor(cosine_similarities)
# value, indices = torch.topk(cosine_similarities,
# max_rec_post_len,
# dim=1,
# largest=True,
# sorted=True)
matrix_list = indices.cpu().numpy()
post_list = list(t_items.keys())
for rec_ids in matrix_list:
rec_ids = [post_list[i] for i in rec_ids]
new_rec_matrix.append(rec_ids)
return new_rec_matrix
def normalize_similarity_adjustments(post_scores, base_similarity,
like_similarity, dislike_similarity):
"""
Normalize the adjustments to keep them in scale with overall similarities.
Args:
post_scores (list): List of post scores.
base_similarity (float): Base similarity score.
like_similarity (float): Similarity score for liked posts.
dislike_similarity (float): Similarity score for disliked posts.
Returns:
float: Adjusted similarity score.
"""
if len(post_scores) == 0:
return base_similarity
max_score = max(post_scores, key=lambda x: x[1])[1]
min_score = min(post_scores, key=lambda x: x[1])[1]
score_range = max_score - min_score
adjustment = (like_similarity - dislike_similarity) * (score_range / 2)
return base_similarity + adjustment
def swap_random_posts(rec_post_ids, post_ids, swap_percent=0.1):
"""
Swap a percentage of recommended posts with random posts.
Args:
rec_post_ids (list): List of recommended post IDs.
post_ids (list): List of all post IDs.
swap_percent (float): Percentage of posts to swap.
Returns:
list: Updated list of recommended post IDs.
"""
num_to_swap = int(len(rec_post_ids) * swap_percent)
posts_to_swap = random.sample(post_ids, num_to_swap)
indices_to_replace = random.sample(range(len(rec_post_ids)), num_to_swap)
for idx, new_post in zip(indices_to_replace, posts_to_swap):
rec_post_ids[idx] = new_post
return rec_post_ids
def get_trace_contents(user_id, action, post_table, trace_table):
"""
Get the contents of posts that a user has interacted with.
Args:
user_id (str): ID of the user.
action (str): Type of action (like or unlike).
post_table (list): List of posts.
trace_table (list): List of user interactions.
Returns:
list: List of post contents.
"""
# Get post IDs from trace table for the given user and action
trace_post_ids = [
trace['post_id'] for trace in trace_table
if (trace['user_id'] == user_id and trace['action'] == action)
]
# Fetch post contents from post table where post IDs match those in the
# trace
trace_contents = [
post['content'] for post in post_table
if post['post_id'] in trace_post_ids
]
return trace_contents
def rec_sys_personalized_with_trace(
user_table: List[Dict[str, Any]],
post_table: List[Dict[str, Any]],
trace_table: List[Dict[str, Any]],
rec_matrix: List[List],
max_rec_post_len: int,
swap_rate: float = 0.1,
) -> List[List]:
"""
This version:
1. If the number of posts is less than or equal to the maximum
recommended length, each user gets all post IDs
2. Otherwise:
- For each user, get a like-trace pool and dislike-trace pool from the
trace table
- For each user, calculate the similarity between the user's bio and
the post text
- Use the trace table to adjust the similarity score
- Swap 10% of the recommended posts with the random posts
Personalized recommendation system that uses user interaction traces.
Args:
user_table (List[Dict[str, Any]]): List of users.
post_table (List[Dict[str, Any]]): List of posts.
trace_table (List[Dict[str, Any]]): List of user interactions.
rec_matrix (List[List]): Existing recommendation matrix.
max_rec_post_len (int): Maximum number of recommended posts.
swap_rate (float): Percentage of posts to swap for diversity.
Returns:
List[List]: Updated recommendation matrix.
"""
start_time = time.time()
new_rec_matrix = []
post_ids = [post['post_id'] for post in post_table]
if len(post_ids) <= max_rec_post_len:
new_rec_matrix = [post_ids] * (len(rec_matrix) - 1)
else:
for idx in range(1, len(rec_matrix)):
user_id = user_table[idx - 1]['user_id']
user_bio = user_table[idx - 1]['bio']
# filter out posts that belong to the user
available_post_contents = [(post['post_id'], post['content'])
for post in post_table
if post['user_id'] != user_id]
# filter out like-trace and dislike-trace
like_trace_contents = get_trace_contents(
user_id, ActionType.LIKE_POST.value, post_table, trace_table)
dislike_trace_contents = get_trace_contents(
user_id, ActionType.UNLIKE_POST.value, post_table, trace_table)
# calculate similarity between user bio and post text
post_scores = []
for post_id, post_content in available_post_contents:
if model is not None:
user_embedding = model.encode(user_bio)
post_embedding = model.encode(post_content)
base_similarity = np.dot(
user_embedding,
post_embedding) / (np.linalg.norm(user_embedding) *
np.linalg.norm(post_embedding))
post_scores.append((post_id, base_similarity))
else:
post_scores.append((post_id, random.random()))
new_post_scores = []
# adjust similarity based on like and dislike traces
for _post_id, _base_similarity in post_scores:
_post_content = post_table[post_ids.index(_post_id)]['content']
like_similarity = sum(
np.dot(model.encode(_post_content), model.encode(like)) /
(np.linalg.norm(model.encode(_post_content)) *
np.linalg.norm(model.encode(like)))
for like in like_trace_contents) / len(
like_trace_contents) if like_trace_contents else 0
dislike_similarity = sum(
np.dot(model.encode(_post_content), model.encode(dislike))
/ (np.linalg.norm(model.encode(_post_content)) *
np.linalg.norm(model.encode(dislike)))
for dislike in dislike_trace_contents) / len(
dislike_trace_contents
) if dislike_trace_contents else 0
# Normalize and apply adjustments
adjusted_similarity = normalize_similarity_adjustments(
post_scores, _base_similarity, like_similarity,
dislike_similarity)
new_post_scores.append((_post_id, adjusted_similarity))
# sort posts by similarity
new_post_scores.sort(key=lambda x: x[1], reverse=True)
# extract post ids
rec_post_ids = [
post_id for post_id, _ in new_post_scores[:max_rec_post_len]
]
if swap_rate > 0:
# swap the recommended posts with random posts
swap_free_ids = [
post_id for post_id in post_ids
if post_id not in rec_post_ids and post_id not in [
trace['post_id']
for trace in trace_table if trace['user_id']
]
]
rec_post_ids = swap_random_posts(rec_post_ids, swap_free_ids,
swap_rate)
new_rec_matrix.append(rec_post_ids)
end_time = time.time()
print(f'Personalized recommendation time: {end_time - start_time:.6f}s')
return new_rec_matrix

View File

@ -0,0 +1,5 @@
CREATE TABLE IF NOT EXISTS `chat_group` (
group_id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

View File

@ -0,0 +1,12 @@
-- This is the schema definition for the comment table
CREATE TABLE comment (
comment_id INTEGER PRIMARY KEY AUTOINCREMENT,
post_id INTEGER,
user_id INTEGER,
content TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
num_likes INTEGER DEFAULT 0,
num_dislikes INTEGER DEFAULT 0,
FOREIGN KEY(post_id) REFERENCES post(post_id),
FOREIGN KEY(user_id) REFERENCES user(user_id)
);

View File

@ -0,0 +1,9 @@
-- This is the schema definition for the comment_dislike table
CREATE TABLE comment_dislike (
comment_dislike_id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
comment_id INTEGER,
created_at DATETIME,
FOREIGN KEY(user_id) REFERENCES user(user_id),
FOREIGN KEY(comment_id) REFERENCES comment(comment_id)
);

View File

@ -0,0 +1,9 @@
-- This is the schema definition for the comment_like table
CREATE TABLE comment_like (
comment_like_id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
comment_id INTEGER,
created_at DATETIME,
FOREIGN KEY(user_id) REFERENCES user(user_id),
FOREIGN KEY(comment_id) REFERENCES comment(comment_id)
);

View File

@ -0,0 +1,9 @@
-- This is the schema definition for the dislike table
CREATE TABLE dislike (
dislike_id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
post_id INTEGER,
created_at DATETIME,
FOREIGN KEY(user_id) REFERENCES user(user_id),
FOREIGN KEY(post_id) REFERENCES tweet(post_id)
);

View File

@ -0,0 +1,9 @@
-- This is the schema definition for the follow table
CREATE TABLE follow (
follow_id INTEGER PRIMARY KEY AUTOINCREMENT,
follower_id INTEGER,
followee_id INTEGER,
created_at DATETIME,
FOREIGN KEY(follower_id) REFERENCES user(user_id),
FOREIGN KEY(followee_id) REFERENCES user(user_id)
);

View File

@ -0,0 +1,7 @@
CREATE TABLE IF NOT EXISTS group_members (
group_id INTEGER NOT NULL,
agent_id INTEGER NOT NULL,
joined_at DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (group_id, agent_id),
FOREIGN KEY (group_id) REFERENCES chat_group(group_id)
);

View File

@ -0,0 +1,9 @@
CREATE TABLE IF NOT EXISTS group_messages (
message_id INTEGER PRIMARY KEY AUTOINCREMENT,
group_id INTEGER NOT NULL,
sender_id INTEGER NOT NULL,
content TEXT NOT NULL,
sent_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (group_id) REFERENCES chat_group(group_id),
FOREIGN KEY (sender_id) REFERENCES user(agent_id)
);

View File

@ -0,0 +1,9 @@
-- This is the schema definition for the like table
CREATE TABLE like (
like_id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
post_id INTEGER,
created_at DATETIME,
FOREIGN KEY(user_id) REFERENCES user(user_id),
FOREIGN KEY(post_id) REFERENCES tweet(post_id)
);

View File

@ -0,0 +1,9 @@
-- This is the schema definition for the mute table
CREATE TABLE mute (
mute_id INTEGER PRIMARY KEY AUTOINCREMENT,
muter_id INTEGER,
mutee_id INTEGER,
created_at DATETIME,
FOREIGN KEY(muter_id) REFERENCES user(user_id),
FOREIGN KEY(mutee_id) REFERENCES user(user_id)
);

View File

@ -0,0 +1,16 @@
-- This is the schema definition for the post table
-- Add Images, location etc.?
CREATE TABLE post (
post_id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
original_post_id INTEGER, -- NULL if this is an original post
content TEXT DEFAULT '', -- DEFAULT '' for initial posts
quote_content TEXT, -- NULL if this is an original post or a repost
created_at DATETIME,
num_likes INTEGER DEFAULT 0,
num_dislikes INTEGER DEFAULT 0,
num_shares INTEGER DEFAULT 0, -- num_shares = num_reposts + num_quotes
num_reports INTEGER DEFAULT 0,
FOREIGN KEY(user_id) REFERENCES user(user_id),
FOREIGN KEY(original_post_id) REFERENCES post(post_id)
);

View File

@ -0,0 +1,6 @@
-- This is the schema definition for the product table
CREATE TABLE product (
product_id INTEGER PRIMARY KEY,
product_name TEXT,
sales INTEGER DEFAULT 0
);

View File

@ -0,0 +1,8 @@
-- This is the schema definition for the rec table
CREATE TABLE rec (
user_id INTEGER,
post_id INTEGER,
PRIMARY KEY(user_id, post_id),
FOREIGN KEY(user_id) REFERENCES user(user_id)
FOREIGN KEY(post_id) REFERENCES tweet(post_id)
);

View File

@ -0,0 +1,10 @@
-- This is the schema definition for the report table
CREATE TABLE report (
report_id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
post_id INTEGER,
report_reason TEXT,
created_at DATETIME,
FOREIGN KEY(user_id) REFERENCES user(user_id),
FOREIGN KEY(post_id) REFERENCES post(post_id)
);

View File

@ -0,0 +1,9 @@
-- This is the schema definition for the trace table
CREATE TABLE trace (
user_id INTEGER,
created_at DATETIME,
action TEXT,
info TEXT,
PRIMARY KEY(user_id, created_at, action, info),
FOREIGN KEY(user_id) REFERENCES user(user_id)
);

View File

@ -0,0 +1,11 @@
-- This is the schema definition for the user table
CREATE TABLE user (
user_id INTEGER PRIMARY KEY AUTOINCREMENT,
agent_id INTEGER,
user_name TEXT,
name TEXT,
bio TEXT,
created_at DATETIME,
num_followings INTEGER DEFAULT 0,
num_followers INTEGER DEFAULT 0
);

View File

@ -0,0 +1,90 @@
# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
# Licensed under the Apache License, Version 2.0 (the “License”);
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an “AS IS” BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
from enum import Enum
class ActionType(Enum):
EXIT = "exit"
REFRESH = "refresh"
SEARCH_USER = "search_user"
SEARCH_POSTS = "search_posts"
CREATE_POST = "create_post"
LIKE_POST = "like_post"
UNLIKE_POST = "unlike_post"
DISLIKE_POST = "dislike_post"
UNDO_DISLIKE_POST = "undo_dislike_post"
REPORT_POST = "report_post"
FOLLOW = "follow"
UNFOLLOW = "unfollow"
MUTE = "mute"
UNMUTE = "unmute"
TREND = "trend"
SIGNUP = "sign_up"
REPOST = "repost"
QUOTE_POST = "quote_post"
UPDATE_REC_TABLE = "update_rec_table"
CREATE_COMMENT = "create_comment"
LIKE_COMMENT = "like_comment"
UNLIKE_COMMENT = "unlike_comment"
DISLIKE_COMMENT = "dislike_comment"
UNDO_DISLIKE_COMMENT = "undo_dislike_comment"
DO_NOTHING = "do_nothing"
PURCHASE_PRODUCT = "purchase_product"
INTERVIEW = "interview"
JOIN_GROUP = "join_group"
LEAVE_GROUP = "leave_group"
SEND_TO_GROUP = "send_to_group"
CREATE_GROUP = "create_group"
LISTEN_FROM_GROUP = "listen_from_group"
@classmethod
def get_default_twitter_actions(cls):
return [
cls.CREATE_POST,
cls.LIKE_POST,
cls.REPOST,
cls.FOLLOW,
cls.DO_NOTHING,
cls.QUOTE_POST,
]
@classmethod
def get_default_reddit_actions(cls):
return [
cls.LIKE_POST,
cls.DISLIKE_POST,
cls.CREATE_POST,
cls.CREATE_COMMENT,
cls.LIKE_COMMENT,
cls.DISLIKE_COMMENT,
cls.SEARCH_POSTS,
cls.SEARCH_USER,
cls.TREND,
cls.REFRESH,
cls.DO_NOTHING,
cls.FOLLOW,
cls.MUTE,
]
class RecsysType(Enum):
TWITTER = "twitter"
TWHIN = "twhin-bert"
REDDIT = "reddit"
RANDOM = "random"
class DefaultPlatformType(Enum):
TWITTER = "twitter"
REDDIT = "reddit"

View File

@ -0,0 +1,13 @@
# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
# Licensed under the Apache License, Version 2.0 (the “License”);
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an “AS IS” BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========

View File

@ -0,0 +1,64 @@
# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
# Licensed under the Apache License, Version 2.0 (the “License”);
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an “AS IS” BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
import logging
import sqlite3
from datetime import datetime
table_log = logging.getLogger(name="table")
table_log.setLevel("DEBUG")
now = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") # Modify here
file_handler = logging.FileHandler(f"./log/table-{str(now)}.log",
encoding="utf-8")
file_handler.setLevel("DEBUG")
file_handler.setFormatter(logging.Formatter("%(message)s"))
table_log.addHandler(file_handler)
stream_handler = logging.StreamHandler()
stream_handler.setLevel("DEBUG")
stream_handler.setFormatter(logging.Formatter("%(message)s"))
table_log.addHandler(stream_handler)
def print_db_contents(db_file):
# Connect to the SQLite database
conn = sqlite3.connect(db_file)
cursor = conn.cursor()
# Retrieve and print all table names
cursor.execute("SELECT name FROM sqlite_master WHERE type='table';")
tables = cursor.fetchall()
# print("Tables:", [table[0] for table in tables])
table_log.info("Tables:" + " ".join([str(table[0]) for table in tables]))
for table_name in tables:
# print(f"\nTable: {table_name[0]}")
table_log.info(f"\nTable: {table_name[0]}")
# Print table structure
cursor.execute(f"PRAGMA table_info({table_name[0]})")
columns = cursor.fetchall()
# print("Columns:")
table_log.info("Columns:")
for col in columns:
# print(f" {col[1]} ({col[2]})")
table_log.info(f" {col[1]} ({col[2]})")
# Print table contents
cursor.execute(f"SELECT * FROM {table_name[0]}")
rows = cursor.fetchall()
# print("Contents:")
table_log.info("Contents:")
for row in rows:
# print(" ", row)
table_log.info(" " + ", ".join(str(item) for item in row))
# Close the connection
conn.close()

View File

@ -0,0 +1,41 @@
[project]
name = "camel-oasis"
version = "0.2.5.post1"
description = "Open Agents Social Interaction Simulations on a Large Scale (vendored fork: relaxed neo4j pin to coexist with graphiti-core[falkordb])"
authors = [{ name = "CAMEL-AI.org" }]
readme = "README.md"
license = { text = "Apache-2.0" }
keywords = ["multi-agent-systems", "social-simulations"]
requires-python = ">=3.11,<3.13"
dependencies = [
"pandas==2.2.2",
"igraph==0.11.6",
"cairocffi==1.7.1",
"pillow==10.3.0",
"unstructured==0.13.7",
"sentence-transformers==3.0.0",
"prance==23.6.21.0",
"openapi-spec-validator==0.7.1",
"slack_sdk==3.31.0",
# Relaxed from ==5.23.0 to coexist with graphiti-core[falkordb]'s
# neo4j>=5.26.0 requirement. The MiroFish code paths (run_*_simulation.py
# + run_parallel_simulation.py) never pass a neo4j_config, so AgentGraph
# is constructed in-memory-only and the neo4j driver is never invoked.
"neo4j>=5.23.0",
"camel-ai==0.2.78",
"requests_oauthlib==2.0.0",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["oasis"]
# Required because the parent project (MiroFish) declares this package as
# a direct-reference local path dep ("camel-oasis @ file:///app/backend/vendor/camel-oasis").
# Without this, hatchling refuses to build when uv is constructing the lock entry.
[tool.hatch.metadata]
allow-direct-references = true

19
deploy/Dockerfile.e2e Normal file
View File

@ -0,0 +1,19 @@
# Lightweight e2e test image. Inherits the MiroFish base image (already
# has the patched graphiti_service.py + all Python deps) and only adds
# the test script. Keeps the image small and the build fast.
#
# Build context: project root (so the parent Dockerfile already has app/).
# The test script is fetched from deploy/ in the project tree.
FROM mirofish:latest
# Drop the long-running start.sh and just run the test
USER root
RUN mkdir -p /app/deploy
COPY deploy/e2e_test.py /app/deploy/e2e_test.py
# Run the test as the entrypoint. The MiroFish image is uv-managed — flask,
# graphiti, falkordb all live under /app/backend/.venv, so we need to invoke
# python via `uv run` to get the venv on PYTHONPATH.
WORKDIR /app/backend
ENTRYPOINT ["uv", "run", "python", "/app/deploy/e2e_test.py"]

98
deploy/README.md Normal file
View File

@ -0,0 +1,98 @@
# MiroFish deploy (agent.profikid.nl)
Single-host Docker deployment of the [MiroFish swarm-intelligence engine](https://github.com/666ghj/MiroFish) reforked to use **Graphiti + FalkorDB** instead of Zep Cloud.
## What you get
| URL | Service |
| --- | --- |
| `https://mirofish.agent.profikid.nl` | MiroFish web UI + API (frontend on :3000, Flask API on :5001, both inside the mirofish container) |
| `mirofish_net` (internal Docker network) | FalkorDB (in-memory graph store, port 6379) |
Traefik (running on the host as the `traefik` Docker network) auto-issues a Let's Encrypt cert for `mirofish.agent.profikid.nl`.
## Files
```
/docker/mirofish/ <-- everything below lives here
├── docker-compose.yml <-- mirofish + falkordb + e2e (opt-in)
├── secrets.env <-- LLM_API_KEY, FALKORDB creds
├── bootstrap.sh <-- one-time host checks
├── up.sh <-- build + bring up mirofish + falkordb
├── e2e.sh <-- run the in-container end-to-end test
├── health.sh <-- /health, container status, falkordb ping
├── Dockerfile.e2e <-- e2e test image (inherits mirofish:latest)
└── e2e_test.py <-- the test itself
```
## Companion: FalkorDB Browser
A separate stack at `/docker/falkordb-browser/` exposes the [FalkorDB Browser](https://github.com/FalkorDB/falkordb-browser) (v2.0.10) at **`https://falkor.agent.profikid.nl`**. It joins the same `mirofish_net` network as the sidecar so the browser UI can read every graph the MiroFish cron produces (MATCH queries, schema explorer, node editor).
To add the connection in the UI:
1. Open `https://falkor.agent.profikid.nl/`
2. On the login page, enter:
- **Host:** `falkordb`
- **Port:** `6379`
- **Username / Password:** leave empty (the MiroFish FalkorDB runs without auth)
3. The browser will list all the `mirofish_*` graphs from the cron ingests.
The browser uses its own network connection (server-side) to dial `falkordb:6379`, so the host field is just an identifier — your browser doesn't need to be able to reach it.
## Deploy
```bash
ssh root@agent.profikid.nl
mkdir -p /docker/mirofish
cd /docker/mirofish
# Drop the contents of this directory here (or git clone the repo and copy deploy/ -> .)
./bootstrap.sh # verifies docker / dns / file presence
./up.sh # builds the image, brings up mirofish + falkordb
```
After ~90s (FalkorDB cold start + embedding model download on first build only), the URL `https://mirofish.agent.profikid.nl` should respond.
## E2E test
```bash
cd /docker/mirofish
./e2e.sh
```
This builds `mirofish-e2e:latest` (a thin layer on top of `mirofish:latest` that runs `e2e_test.py` as the entrypoint), then runs it as a one-shot container against the real `falkordb` sidecar. It:
1. Verifies FalkorDB is reachable (raw TCP probe)
2. Runs the real `GraphBuilderService` against the Iran/US/Israel OSINT briefing seed text
3. Reads back from FalkorDB via the adapter (`get_all_nodes`, `get_all_edges`)
4. Re-verifies with a raw `falkordb.FalkorDB` client (independent check)
5. Prints PASS / FAIL with node + edge counts
Expected on first run: **~8 entities, ~8 edges** in FalkorDB after the 2-chunk run (`E2E_MAX_CHUNKS=2`). Wall time ~3-4min on the first call (LLM is doing the extraction; reasoning tokens make M3 slow). Tune `E2E_MAX_CHUNKS` higher in `docker-compose.yml` to test with more seed text.
## How the refactor differs from upstream
Upstream MiroFish uses [Zep Cloud](https://www.getzep.com/) for the knowledge graph. This fork replaces it with [Graphiti](https://github.com/getzep/graphiti) (the open-source engine behind Zep) backed by [FalkorDB](https://www.falkordb.com/) (a Redis-protocol graph store). All graph operations go through a Zep-shaped facade in `backend/app/services/graphiti_service.py` so the rest of the MiroFish code (which still speaks Zep) didn't have to change.
Key wiring:
- `MinimaxLLMClient` — Graphiti's LLMClient ABC implemented against MiniMax M3 (`https://api.minimax.io/v1`). Uses the `tools` API for structured output because M3 ignores `response_format: json_object`.
- `M3RerankerClient` — chat-completion True/False reranker (M3 returns `logprobs: None`, so the stock OpenAI reranker that reads logprobs breaks).
- `HashEmbedder` — deterministic 384-dim embedder so the test image doesn't need to download the full `sentence-transformers` multilingual model. The production image still uses the real model (set `EMBEDDING_MODEL=sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2` in `secrets.env` to switch).
- `FalkorDriver` (from `graphiti-core[falkordb]>=0.20.0`) — the actual graph store. No Neo4j, no Bolt, just a Redis-protocol TCP socket to the sidecar.
## How to upgrade
```bash
cd /docker/mirofish
git pull # in the parent MiroFish repo
./up.sh --rebuild # rebuild + restart
./e2e.sh # smoke-test
```
## Troubleshooting
- **FalkorDB healthcheck fails** — check `docker compose logs falkordb`. The `falkordb/falkordb:latest` image is small; first-pull can take a minute.
- **`/health` returns 200 but `/api/ontology/generate` times out** — the embedding model is downloading on first request. Wait, or run with a prebuilt `mirofish:latest` that has the model baked in (the `Dockerfile` already does this in step 4).
- **M3 returns 0 entities** — the M3 endpoint is shared; rate limiting or reasoning-token quirks can cause this. Check `docker compose logs mirofish | grep graphiti_service` and the raw LLM payloads.
- **Cert issuance stuck in Traefik** — confirm `TRAEFIK_HOST=agent.profikid.nl` and that the `traefik` Docker network exists. Traefik must be on the same network as the `mirofish` container.

43
deploy/bootstrap.sh Executable file
View File

@ -0,0 +1,43 @@
#!/usr/bin/env bash
# MiroFish host bootstrap — agent.profikid.nl
#
# Run this ONCE on the host (after cloning the repo to /docker/mirofish/).
# It creates the project directory layout and verifies Docker + Traefik are
# reachable. Does NOT actually deploy — that's the `up.sh` next to this file.
#
# Usage: ./bootstrap.sh
set -euo pipefail
cd "$(dirname "$0")"
echo "[bootstrap] creating /docker/mirofish/ if missing..."
sudo mkdir -p /docker/mirofish
sudo chown -R "$USER":"$USER" /docker/mirofish || true
echo "[bootstrap] required files present:"
for f in docker-compose.yml secrets.env Dockerfile Dockerfile.e2e e2e_test.py; do
if [ ! -f "$f" ] && [ ! -f "../$f" ] && [ ! -f "../deploy/$f" ]; then
echo " MISSING: $f" >&2
exit 1
fi
echo " OK: $f"
done
echo "[bootstrap] checking docker..."
docker --version
docker compose version
echo "[bootstrap] checking traefik network..."
docker network ls --format '{{.Name}}' | grep -q '^traefik$' \
|| { echo " WARN: 'traefik' network not found. Traefik must be on a network called 'traefik' (or edit docker-compose.yml)." >&2; }
echo "[bootstrap] DNS for agent.profikid.nl (must resolve to 69.62.114.199)..."
RESOLVED=$(getent hosts agent.profikid.nl | awk '{print $1; exit}')
if [ "$RESOLVED" != "69.62.114.199" ]; then
echo " WARN: agent.profikid.nl resolves to $RESOLVED, expected 69.62.114.199" >&2
fi
echo
echo "[bootstrap] all checks passed."
echo
echo "Next: edit secrets.env if you need to change LLM keys, then run ./up.sh"

104
deploy/docker-compose.yml Normal file
View File

@ -0,0 +1,104 @@
# MiroFish deploy overlay — agent.profikid.nl
#
# Project conventions (matches hermes, snake, wiki siblings):
# /docker/<name>/docker-compose.yml
# COMPOSE_PROJECT_NAME=<name>
# TRAEFIK_HOST=agent.profikid.nl
#
# Single port (3000) exposed to Traefik; nginx inside the container
# serves the built frontend and reverse-proxies /api/* + /health to Flask.
#
# Memory graph: FalkorDB (in-memory graph store) on a shared network.
services:
falkordb:
image: falkordb/falkordb:latest
container_name: mirofish-falkordb
restart: unless-stopped
expose:
- "6379"
volumes:
- falkordb-data:/data
healthcheck:
test: ["CMD-SHELL", "redis-cli ping | grep -q PONG || exit 1"]
interval: 15s
timeout: 3s
retries: 5
start_period: 20s
networks:
- mirofish_net
mirofish:
build:
context: ..
dockerfile: Dockerfile
image: mirofish:latest
container_name: mirofish
restart: unless-stopped
depends_on:
falkordb:
condition: service_healthy
env_file:
- secrets.env
environment:
FALKORDB_HOST: falkordb
FALKORDB_PORT: "6379"
expose:
- "3000"
labels:
- traefik.enable=true
- traefik.http.routers.${COMPOSE_PROJECT_NAME:-mirofish}.rule=Host(`${COMPOSE_PROJECT_NAME:-mirofish}.${TRAEFIK_HOST:-agent.profikid.nl}`)
- traefik.http.routers.${COMPOSE_PROJECT_NAME:-mirofish}.entrypoints=websecure
- traefik.http.routers.${COMPOSE_PROJECT_NAME:-mirofish}.tls.certresolver=letsencrypt
- traefik.http.services.${COMPOSE_PROJECT_NAME:-mirofish}.loadbalancer.server.port=3000
# HTTP -> HTTPS redirect
- traefik.http.routers.${COMPOSE_PROJECT_NAME:-mirofish}-http.rule=Host(`${COMPOSE_PROJECT_NAME:-mirofish}.${TRAEFIK_HOST:-agent.profikid.nl}`)
- traefik.http.routers.${COMPOSE_PROJECT_NAME:-mirofish}-http.entrypoints=web
- traefik.http.routers.${COMPOSE_PROJECT_NAME:-mirofish}-http.middlewares=redirect-to-https
- traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https
- traefik.http.middlewares.redirect-to-https.redirectscheme.permanent=true
volumes:
- uploads:/app/backend/uploads
- flask-logs:/app/logs
- embedding-model:/root/.cache/huggingface # cache the sentence-transformers model
healthcheck:
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:3000/health"]
interval: 30s
timeout: 5s
retries: 5
start_period: 90s
networks:
- mirofish_net
# One-shot e2e test. Built but only runs when you opt in:
# docker compose --profile e2e run --rm mirofish-e2e
# Output is printed to the host (PASS/FAIL exit code).
mirofish-e2e:
build:
context: ..
dockerfile: deploy/Dockerfile.e2e
image: mirofish-e2e:latest
container_name: mirofish-e2e
depends_on:
falkordb:
condition: service_healthy
env_file:
- secrets.env
environment:
FALKORDB_HOST: falkordb
FALKORDB_PORT: "6379"
E2E_MAX_CHUNKS: "2"
networks:
- mirofish_net
profiles: ["e2e"]
restart: "no"
volumes:
uploads:
flask-logs:
falkordb-data:
embedding-model:
networks:
mirofish_net:
name: mirofish_net

28
deploy/e2e.sh Executable file
View File

@ -0,0 +1,28 @@
#!/usr/bin/env bash
# MiroFish e2e — runs the in-container e2e test against the real FalkorDB
# sidecar. Prints PASS/FAIL and exits accordingly.
set -euo pipefail
cd "$(dirname "$0")"
export COMPOSE_PROJECT_NAME=mirofish
# Make sure the e2e image is built
if ! docker image inspect mirofish-e2e:latest >/dev/null 2>&1; then
echo "[e2e] building mirofish-e2e image..."
docker compose --profile e2e build mirofish-e2e
fi
echo "[e2e] running test against falkordb sidecar..."
# `run --rm` creates a one-off container with the e2e profile, runs the test,
# and removes the container on exit. Exit code propagates.
docker compose --profile e2e run --rm mirofish-e2e
RC=$?
echo
if [ "$RC" -eq 0 ]; then
echo "[e2e] PASS"
else
echo "[e2e] FAIL (exit $RC)"
fi
exit "$RC"

209
deploy/e2e_test.py Normal file
View File

@ -0,0 +1,209 @@
"""
MiroFish FalkorDB end-to-end test (production-mode).
Runs the same code path the API uses, but in a one-shot container, against
the real FalkorDB sidecar in the same compose project. Verifies that:
- FalkorDB is reachable from the app container (real TCP, real protocol)
- graphiti-core connects and the Zep-shaped adapter works
- Entities + edges actually land in FalkorDB after add_episode
- The /health endpoint on the running MiroFish container is 200
Exits 0 on PASS, 1 on FAIL.
"""
from __future__ import annotations
import json
import os
import sys
import time
import traceback
from pathlib import Path
# Make MiroFish backend importable
MIROFISH_BACKEND = "/app/backend"
if MIROFISH_BACKEND not in sys.path:
sys.path.insert(0, MIROFISH_BACKEND)
# .env is loaded by MiroFish's config.py; nothing to do here.
print("=" * 70)
print("MIROFISH END-TO-END TEST (FalkorDB backend)")
print("=" * 70)
print(f" FALKORDB_HOST = {os.environ.get('FALKORDB_HOST', '<unset>')}")
print(f" FALKORDB_PORT = {os.environ.get('FALKORDB_PORT', '<unset>')}")
print(f" LLM_BASE_URL = {os.environ.get('LLM_BASE_URL', '<unset>')}")
print(f" LLM_MODEL_NAME = {os.environ.get('LLM_MODEL_NAME', '<unset>')}")
print(f" EMBEDDING_MODEL= {os.environ.get('EMBEDDING_MODEL','<unset>')}")
print()
# -- 0. Sanity: is FalkorDB up? Use a raw TCP probe (no app deps) --
import socket
fk_host = os.environ.get("FALKORDB_HOST", "falkordb")
fk_port = int(os.environ.get("FALKORDB_PORT", "6379"))
try:
with socket.create_connection((fk_host, fk_port), timeout=5):
print(f"[0] FalkorDB TCP reachable at {fk_host}:{fk_port}")
except OSError as e:
print(f"[0] FAIL: FalkorDB not reachable at {fk_host}:{fk_port}: {e}")
sys.exit(2)
# -- 1. The Iran/US/Israel OSINT cron briefing as the seed text --
# In a real MiroFish run, this comes from /home/hermes/.hermes/cron/output/...
# For the e2e test, we ship a copy of the latest briefing inline.
SEED_TEXT = """\
## IRAN/US/ISRAEL CONFLICT — UPDATE
**Timestamp:** Monday, 8 June 2026, ~21:00 UTC
**Coverage Period:** Last 12 hours (0708 June 2026)
### INCIDENTS / HOSTILITIES
- **Iran launched ballistic missile attack on Israel** (Sun 7 Jun, evening local) first direct Iranian bombardment since the April 8 ceasefire. Multiple projectiles struck central Israel; Israeli emergency services report **3 killed and >100 injured** in a strike on a building in **Bat Yam** (south of Tel Aviv). [BBC, NYT, AP]
- **Israeli retaliatory strikes on Iran** conducted hours after the Iranian launch, targeting military sites in Tehran. Netanyahu stated Israel "hit the terror regime in Tehran." [NYT, Times of Israel]
- **Hezbollah rocket fire on northern Israel** (7 Jun) preceded the Israeli strike on Beirut's southern suburbs, which killed **2 and wounded ~1120** (Lebanese state media). [NPR]
- **Iranian drone attack on US interests in the Strait of Hormuz** US Central Command reports shooting down a pair of Iranian drones threatening the strait on 7 Jun. [CBS]
### DIPLOMATIC / POLITICAL
- **Trump-Netanyahu phone call** (7 Jun, post-Iranian missile launch) Trump urged Netanyahu **not to retaliate**; Netanyahu reportedly replied "The Iranians violated our sovereignty."
- **Iran declared halt to military offensive** against Israel on 8 Jun.
### CEASEFIRE / TRUCE STATUS
- **April 8 Iran-US ceasefire:** Functionally **broken** first direct Iran-Israel missile exchange since the truce.
### REGIONAL SPILLOVER
- **Strait of Hormuz:** US naval blockade in effect; shipping at near-standstill since March. IRGC restricts traffic; ~150+ tankers anchored outside. US forces disabled 4 vessels, redirected 94. [CNN, Reuters]
- **Houthi attacks (Yemen):** Iran-backed Houthi rebels struck Israel; rocket debris found near Jericho (West Bank) on 8 Jun.
- **Iraq/Syria:** US radar sites attacked; Iranian drones shot down near US positions (per US claims, 6 Jun).
- **Kuwait/Bahrain:** Earlier Iranian ballistic missile volleys (25 Jun) hit Kuwait International Airport.
### ASSESSMENT
The April 8 ceasefire has effectively collapsed after the first direct Iran-Israel missile exchange in two months, triggered by Israel's Beirut strike on Hezbollah. Both sides have paused strikes as of 8 Jun following Trump intervention, but a 60-day US-Iran deal remains unsigned and Hezbollah's rejection of the Lebanon track preserves a major escalation vector. **High risk of renewed escalation within 2472 hours** if Trump-Netanyahu alignment fractures or Lebanon track collapses.
"""
print(f"[1] Seed text: {len(SEED_TEXT)} chars (Iran/US/Israel OSINT briefing)")
# -- 2. Ontology (same shape MiroFish's OntologyGenerator would emit) --
ONTOLOGY = {
"entity_types": [
{"name": "Country", "description": "A sovereign nation or state actor.",
"attributes": [{"name": "iso_code", "description": "ISO code"},
{"name": "government_type", "description": "Form of government"}]},
{"name": "Person", "description": "A named individual mentioned in the briefing.",
"attributes": [{"name": "role", "description": "Title, position, or affiliation"}]},
{"name": "Organization","description": "A formal organization, military unit, or political body.",
"attributes": [{"name": "type", "description": "e.g. political, militant, state"}]},
{"name": "Location", "description": "A geographic place, city, or strategic region.",
"attributes": [{"name": "region", "description": "Broader region"}]},
{"name": "Event", "description": "A distinct incident, attack, meeting, or action.",
"attributes": [{"name": "date", "description": "When the event occurred"},
{"name": "event_type", "description": "Type of event"}]},
],
"edge_types": [
{"name": "LAUNCHED_ATTACK_AGAINST", "description": "Actor performed a military strike on target.",
"source_targets": [{"source": "Country", "target": "Country"},
{"source": "Organization", "target": "Country"}],
"attributes": [{"name": "date", "description": "Date"},
{"name": "weapon", "description": "Weapon used"}]},
{"name": "OCCURRED_IN", "description": "An event happened at a location.",
"source_targets": [{"source": "Event", "target": "Location"}],
"attributes": [{"name": "date", "description": "Date"}]},
{"name": "IS_LEADER_OF", "description": "A person leads a country or organization.",
"source_targets": [{"source": "Person", "target": "Country"},
{"source": "Person", "target": "Organization"}],
"attributes": []},
{"name": "MADE_STATEMENT", "description": "A person or organization issued a public statement.",
"source_targets": [{"source": "Person", "target": "Event"}],
"attributes": [{"name": "stance", "description": "Position taken"}]},
{"name": "PARTICIPATED_IN", "description": "A country or organization was involved in an event.",
"source_targets": [{"source": "Country", "target": "Event"}],
"attributes": []},
],
}
print(f"[2] Ontology: {len(ONTOLOGY['entity_types'])} entity types, "
f"{len(ONTOLOGY['edge_types'])} edge types")
# -- 3. Chunk the seed text --
from app.services.text_processor import TextProcessor
chunks = TextProcessor.split_text(SEED_TEXT, chunk_size=500, overlap=50)
print(f"[3] Text split: {len(chunks)} chunks")
# -- 4. Run the build pipeline via the real GraphBuilderService --
from app.services.graph_builder import GraphBuilderService
from app.services import graph_builder as _gb_mod # for the EpisodeData stub
EpisodeData = _gb_mod.EpisodeData
MAX_CHUNKS = int(os.environ.get("E2E_MAX_CHUNKS", "2"))
test_chunks = chunks[:MAX_CHUNKS]
print(f"[4] Using {len(test_chunks)} chunks for the e2e test (E2E_MAX_CHUNKS={MAX_CHUNKS})")
print()
print("[5] Running graph build pipeline...")
t0 = time.time()
builder = GraphBuilderService()
graph_id = builder.create_graph(name="Iran_US_Israel_OSINT_e2e")
print(f" create_graph -> {graph_id}")
builder.set_ontology(graph_id, ONTOLOGY)
print(f" set_ontology -> cached {len(ONTOLOGY['entity_types'])} types, {len(ONTOLOGY['edge_types'])} edges")
episodes = [EpisodeData(data=c, type="text") for c in test_chunks]
print(f" add_batch -> sending {len(episodes)} EpisodeData objects...")
batch_result = builder.client.add_batch(graph_id=graph_id, episodes=episodes)
t1 = time.time()
print(f" add_batch took {t1 - t0:.1f}s")
for r in batch_result:
print(f" uuid={r.uuid_} processed={r.processed}")
# -- 5. Verify in FalkorDB via the adapter's read methods --
print()
print("[6] Reading back from FalkorDB via the adapter...")
nodes = builder.client.get_all_nodes(graph_id)
edges = builder.client.get_all_edges(graph_id)
print(f" nodes: {len(nodes)}")
print(f" edges: {len(edges)}")
print()
print("[7] Sample entities (first 10):")
for n in nodes[:10]:
labels_str = ",".join(n.labels) if n.labels else "(no labels)"
summary_preview = (n.summary[:60] + "...") if n.summary and len(n.summary) > 60 else (n.summary or "")
print(f" [{labels_str}] {n.name!r} uuid={n.uuid_[:8]} summary={summary_preview!r}")
print()
print("[8] Sample relationships (first 5):")
for e in edges[:5]:
src = e.source_node_uuid[:8] if e.source_node_uuid else "?"
dst = e.target_node_uuid[:8] if e.target_node_uuid else "?"
fact_preview = (e.fact[:60] + "...") if e.fact and len(e.fact) > 60 else (e.fact or "")
print(f" ({src}) -[{e.name or e.fact_type}]→ ({dst}) fact={fact_preview!r}")
# -- 6. Independent verification with the raw falkordb client --
print()
print("[9] Direct falkordb verification (source of truth)...")
import falkordb
client = falkordb.FalkorDB(host=fk_host, port=fk_port)
graph = client.select_graph(graph_id)
n_count = graph.query("MATCH (n) RETURN count(n) AS c").result_set
e_count = graph.query("MATCH ()-[r]->() RETURN count(r) AS c").result_set
print(f" FalkorDB MATCH count(n) = {n_count}")
print(f" FalkorDB MATCH count(edges) = {e_count}")
labels = graph.query("MATCH (n) UNWIND labels(n) AS l RETURN l, count(*) AS c ORDER BY c DESC LIMIT 20")
print(f" Entity label distribution:")
for row in labels.result_set:
print(f" {row[0]}: {row[1]}")
names = graph.query("MATCH (n) RETURN n.name AS name LIMIT 20")
print(f" Sample node names: {[r[0] for r in names.result_set]}")
# -- 7. Final summary --
print()
print("=" * 70)
print("E2E TEST SUMMARY")
print("=" * 70)
print(f" Graph ID: {graph_id}")
print(f" Seed text: Iran/US/Israel OSINT briefing ({len(SEED_TEXT)} chars)")
print(f" Chunks ingested: {len(episodes)}")
print(f" Nodes in FalkorDB: {len(nodes)} (direct: {n_count[0][0]})")
print(f" Edges in FalkorDB: {len(edges)} (direct: {e_count[0][0]})")
print(f" Wall time: {t1 - t0:.1f}s")
print(f" Pass criteria: node_count > 0 AND edge_count > 0")
passing = (len(nodes) > 0 and len(edges) > 0)
print(f" RESULT: {'PASS' if passing else 'FAIL'}")
print("=" * 70)
sys.exit(0 if passing else 1)

19
deploy/health.sh Executable file
View File

@ -0,0 +1,19 @@
#!/usr/bin/env bash
# Quick healthcheck — /health endpoint, container status, FalkorDB ping.
set -euo pipefail
cd "$(dirname "$0")"
echo "[health] docker compose ps:"
docker compose ps
echo
echo "[health] mirofish /health:"
docker compose exec -T mirofish curl -sS -m 5 http://127.0.0.1:3000/health || echo " /health FAILED"
echo
echo "[health] falkordb ping:"
docker compose exec -T falkordb redis-cli ping || echo " redis-cli FAILED"
echo
echo "[health] falkordb graph list:"
docker compose exec -T falkordb redis-cli GRAPH.LIST || echo " GRAPH.LIST FAILED"

View File

@ -0,0 +1,31 @@
# MiroFish secrets for the agent.profikid.nl deployment.
#
# Rename to `secrets.env` and fill in. Filename is `secrets.env` (not `.env`)
# on purpose — Hermes' safety guard blocks read/write of any file literally
# named `.env`.
#
# DO NOT COMMIT a populated secrets.env to git. This template IS in git
# (secrets.env.example); the real one is gitignored.
#
# LLM provider: MiniMax M3 via the OpenAI-compat endpoint
# (verified working: https://api.minimax.io/v1 returns chat completions for model "MiniMax-M3")
LLM_API_KEY=sk-cp-REPLACE-ME
LLM_BASE_URL=https://api.minimax.io/v1
LLM_MODEL_NAME=MiniMax-M3
# Memory graph backend: Graphiti + FalkorDB (replaces Zep)
# Host/port are set in docker-compose.yml; the keys here are auth (FalkorDB
# ships without auth, so we leave username/password empty for on-prem).
FALKORDB_HOST=falkordb
FALKORDB_PORT=6379
FALKORDB_USERNAME=
FALKORDB_PASSWORD=
# Embedding model. Two options:
# 1) Local sentence-transformers, pre-downloaded in the image (set this):
# sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2
# 2) Lightweight deterministic hash embedder (used by the e2e test image,
# useful for CI / quick smoke tests):
# hash
EMBEDDING_MODEL=sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2

46
deploy/up.sh Executable file
View File

@ -0,0 +1,46 @@
#!/usr/bin/env bash
# MiroFish up — agent.profikid.nl
#
# Builds the image, brings up mirofish + falkordb sidecar on the
# mirofish_net network, registers Traefik routes. Idempotent.
#
# Usage:
# ./up.sh # build + up -d
# ./up.sh --no-build # up -d, skip build
# ./up.sh --rebuild # force rebuild (no cache)
set -euo pipefail
cd "$(dirname "$0")"
BUILD_FLAGS=()
if [ "${1:-}" = "--no-build" ]; then
BUILD_FLAGS+=(--no-build)
elif [ "${1:-}" = "--rebuild" ]; then
BUILD_FLAGS+=(--build --force-rm --no-cache --pull)
else
BUILD_FLAGS+=(--build)
fi
export COMPOSE_PROJECT_NAME=mirofish
export TRAEFIK_HOST=agent.profikid.nl
echo "[up] COMPOSE_PROJECT_NAME=$COMPOSE_PROJECT_NAME"
echo "[up] TRAEFIK_HOST=$TRAEFIK_HOST"
echo "[up] building..."
docker compose "${BUILD_FLAGS[@]}"
echo "[up] bringing up mirofish + falkordb..."
docker compose up -d
echo "[up] tailing logs for ~20s while healthchecks settle..."
docker compose logs --tail=200 --since=2m &
LOG_PID=$!
sleep 20
kill "$LOG_PID" 2>/dev/null || true
echo
echo "[up] status:"
docker compose ps
echo
echo "URL (after Traefik cert): https://mirofish.agent.profikid.nl"
echo "E2E test: ./e2e.sh (runs the in-container e2e test against real FalkorDB)"

View File

@ -1,7 +1,7 @@
services:
mirofish:
image: ghcr.io/666ghj/mirofish:latest
# 加速镜像(如拉取缓慢可替换上方地址)
# Accelerated registry mirror (replace the upstream address above if pulling is slow)
# image: ghcr.nju.edu.cn/666ghj/mirofish:latest
container_name: mirofish
env_file:

View File

@ -1,15 +1,15 @@
<!doctype html>
<html lang="zh">
<html lang="nl">
<head>
<script>document.documentElement.lang = localStorage.getItem('locale') || 'zh'</script>
<script>document.documentElement.lang = localStorage.getItem('locale') || 'nl'</script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@100..800&family=Noto+Sans+SC:wght@300;400;500;700;800;900&family=Space+Grotesk:wght@300..700&display=swap" rel="stylesheet">
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/icon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="MiroFish - 社交媒体舆论模拟系统" />
<title>MiroFish - 预测万物</title>
<meta name="description" content="MiroFish - Social media opinion simulation system" />
<title>MiroFish - Predict Anything</title>
</head>
<body>
<div id="app"></div>

Some files were not shown because too many files have changed in this diff Show More