MicroFish/backend/app/services/simulation_manager.py

788 lines
40 KiB
Python

"""
Trình Quản lý Mô Phỏng OASIS
Đảm nhiệm xây dựng và điều phối chạy mô phỏng song song trên hai nền tảng giả lập Twitter và Reddit.
Sử dụng các kịch bản có sẵn kết hợp cùng LLM để thiết lập thông minh bộ tham số mô phỏng.
"""
import os
import json
import shutil
from typing import Dict, Any, List, Optional
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
from ..config import Config
from ..utils.logger import get_logger
from .zep_entity_reader import ZepEntityReader, FilteredEntities
from .oasis_profile_generator import OasisProfileGenerator, OasisAgentProfile
from .simulation_config_generator import SimulationConfigGenerator, SimulationParameters
logger = get_logger('mirofish.simulation')
# ==============================================================================
# ENUM: SimulationStatus — Máy trạng thái (State Machine) của một Simulation
# ==============================================================================
# Mỗi simulation đi qua các trạng thái theo thứ tự:
#
# CREATED ──► PREPARING ──► READY ──► RUNNING ──► COMPLETED
# │ │
# └──────────────────────┴──► FAILED
# │
# └──► PAUSED ──► RUNNING (tiếp tục)
# │
# └──► STOPPED (người dùng chủ động dừng)
#
# Lưu ý: FAILED là trạng thái cuối cùng không thể tiếp tục — phải tạo simulation mới.
# ==============================================================================
class SimulationStatus(str, Enum):
"""Trạng thái hiện tại của quá trình mô phỏng"""
CREATED = "created" # Vừa được tạo, chưa làm gì cả
PREPARING = "preparing" # Đang chạy prepare_simulation() — đọc entity, sinh profile, tạo config
READY = "ready" # prepare_simulation() hoàn thành — sẵn sàng bấm nút chạy OASIS
RUNNING = "running" # OASIS subprocess đang chạy, agents đang hành động
PAUSED = "paused" # Người dùng ra lệnh pause qua IPC — OASIS tạm dừng vòng lặp
STOPPED = "stopped" # Người dùng chủ động dừng — OASIS bị kill, kết quả còn lại được giữ
COMPLETED = "completed" # OASIS chạy hết số vòng (max_rounds) và thoát thành công
FAILED = "failed" # Exception không xử lý được — pipeline bị gián đoạn
class PlatformType(str, Enum):
"""Phân loại nền tảng giả lập Mạng xã hội"""
TWITTER = "twitter"
REDDIT = "reddit"
# ==============================================================================
# DATACLASS: SimulationState — "Hộp đen" lưu toàn bộ thông tin một simulation
# ==============================================================================
# Đây là đối tượng trung tâm được đọc/ghi liên tục trong suốt vòng đời simulation.
# Nó được lưu ở hai nơi đồng thời:
# 1. RAM: `SimulationManager._simulations` dict (tra cứu nhanh O(1))
# 2. Disk: `<SIMULATION_DATA_DIR>/<simulation_id>/state.json` (bền vững qua restart)
# ==============================================================================
@dataclass
class SimulationState:
"""Class lưu trữ cấu trúc Dữ liệu/Trạng thái của một lượt mô phỏng"""
# --- Định danh ---
simulation_id: str # UUID dạng "sim_<12 ký tự hex>", ví dụ: "sim_a3f8c21b004e"
project_id: str # ID của Project chứa simulation này (quản lý nhiều sim cùng lúc)
graph_id: str # ID của Zep Graph — xác định kho Entity nào sẽ dùng làm agent
# --- Công tắc nền tảng ---
# Cho phép chọn chạy 1 hoặc cả 2 nền tảng. Nếu cả hai False → pipeline sẽ bỏ qua bước sinh profile.
enable_twitter: bool = True
enable_reddit: bool = True
# --- Trạng thái vòng đời ---
# Giá trị mặc định CREATED; được cập nhật qua _save_simulation_state() mỗi khi chuyển giai đoạn.
status: SimulationStatus = SimulationStatus.CREATED
# --- Kết quả thống kê của giai đoạn PREPARING ---
entities_count: int = 0 # Số entity đọc được từ Zep (sau khi lọc)
profiles_count: int = 0 # Số agent profile đã sinh thành công
entity_types: List[str] = field(default_factory=list) # Danh sách loại entity (ví dụ: ["Person", "Organization"])
# --- Kết quả sinh config ---
config_generated: bool = False # True sau khi simulation_config.json được ghi ra đĩa
config_reasoning: str = "" # Chuỗi giải thích của LLM tại sao chọn các tham số này
# --- Dữ liệu runtime (cập nhật theo thời gian thực trong khi RUNNING) ---
current_round: int = 0 # Vòng hiện tại OASIS đang chạy (0-indexed)
twitter_status: str = "not_started" # Trạng thái chi tiết của luồng Twitter
reddit_status: str = "not_started" # Trạng thái chi tiết của luồng Reddit
# --- Timestamp lịch sử ---
# Lưu dạng ISO 8601 (ví dụ: "2025-01-15T10:30:45.123456") để dễ parse lại
created_at: str = field(default_factory=lambda: datetime.now().isoformat())
updated_at: str = field(default_factory=lambda: datetime.now().isoformat())
# --- Lỗi ---
# None nếu không có lỗi; nếu có chứa message dạng string để hiển thị cho frontend
error: Optional[str] = None
def to_dict(self) -> Dict[str, Any]:
"""
Xuất toàn bộ state thành dict đầy đủ.
Dùng khi:
- Ghi ra file state.json (để khôi phục lại khi server restart)
- Truyền nội bộ giữa các service Python
Khác với to_simple_dict(): bản này có đầy đủ mọi trường,
kể cả các trường nặng như config_reasoning và timestamps.
"""
return {
"simulation_id": self.simulation_id,
"project_id": self.project_id,
"graph_id": self.graph_id,
"enable_twitter": self.enable_twitter,
"enable_reddit": self.enable_reddit,
"status": self.status.value, # Enum → string (ví dụ: "preparing")
"entities_count": self.entities_count,
"profiles_count": self.profiles_count,
"entity_types": self.entity_types,
"config_generated": self.config_generated,
"config_reasoning": self.config_reasoning,
"current_round": self.current_round,
"twitter_status": self.twitter_status,
"reddit_status": self.reddit_status,
"created_at": self.created_at,
"updated_at": self.updated_at,
"error": self.error,
}
def to_simple_dict(self) -> Dict[str, Any]:
"""
Xuất state thành dict gọn nhẹ cho API response trả về client/frontend.
Lược bỏ các trường nội bộ nặng (config_reasoning, timestamps, platform statuses...)
để giảm payload JSON và không để lộ thông tin debug ra ngoài.
"""
return {
"simulation_id": self.simulation_id,
"project_id": self.project_id,
"graph_id": self.graph_id,
"status": self.status.value,
"entities_count": self.entities_count,
"profiles_count": self.profiles_count,
"entity_types": self.entity_types,
"config_generated": self.config_generated,
"error": self.error,
}
# ==============================================================================
# CLASS: SimulationManager — Trung tâm điều phối toàn bộ vòng đời simulation
# ==============================================================================
# SimulationManager là singleton (dùng chung 1 instance) được Flask khởi tạo lúc boot.
# Nó KHÔNG chạy OASIS trực tiếp — việc đó do SimulationRunner đảm nhiệm.
# Nhiệm vụ chính: chuẩn bị dữ liệu (prepare) và theo dõi trạng thái (state tracking).
# ==============================================================================
class SimulationManager:
"""
Kịch bản Quản lý trung tâm của tính năng Mô Phỏng
Luồng thiết lập cốt lõi:
1. Trích xuất nhóm các Thực Thể (Entity) được định nghĩa sẵn trong hệ thống lưu trữ Graph của Zep.
2. Chế lại thành các hồ sơ Profile thiết lập tiêu chuẩn của OASIS framework (Agent)
3. Trao quyền cho sức mạnh mô hình LLM tự đánh giá số liệu rồi tự sinh ra cấu hình cài đặt cho quá trình mô phỏng
4. Cài đặt các thư mục và tập tin tương ứng, phục vụ sẵn sàng để những Script lập trình riêng (pre-set script) có thể khai thác sử dụng.
"""
# Đường dẫn gốc nơi lưu tất cả dữ liệu simulation (mỗi sim có subfolder riêng).
# __file__ = .../backend/app/services/simulation_manager.py
# → join 2 cấp "../.." → .../backend/
# → join "uploads/simulations" → .../backend/uploads/simulations/
SIMULATION_DATA_DIR = os.path.join(
os.path.dirname(__file__),
'../../uploads/simulations'
)
def __init__(self):
# Tạo thư mục gốc nếu chưa tồn tại (exist_ok=True → không báo lỗi nếu đã có)
os.makedirs(self.SIMULATION_DATA_DIR, exist_ok=True)
# Cache RAM: dict ánh xạ simulation_id → SimulationState
# Mục đích: tránh đọc file state.json mỗi lần có request API → tra cứu O(1)
# Nhược điểm: mất dữ liệu khi server restart → có disk backup bù lại
self._simulations: Dict[str, SimulationState] = {}
# --------------------------------------------------------------------------
# PRIVATE HELPERS — Quản lý file/thư mục và cơ chế hai lớp cache
# --------------------------------------------------------------------------
def _get_simulation_dir(self, simulation_id: str) -> str:
"""
Trả về đường dẫn thư mục chứa dữ liệu của một simulation cụ thể.
Tự động tạo thư mục nếu chưa tồn tại (lazy creation).
Cấu trúc thư mục sau khi READY:
uploads/simulations/<simulation_id>/
├── state.json ← trạng thái vòng đời
├── simulation_config.json ← tham số OASIS do LLM sinh
├── reddit_profiles.json ← agent profiles cho Reddit
└── twitter_profiles.csv ← agent profiles cho Twitter
"""
sim_dir = os.path.join(self.SIMULATION_DATA_DIR, simulation_id)
os.makedirs(sim_dir, exist_ok=True) # Idempotent: gọi nhiều lần vẫn an toàn
return sim_dir
def _save_simulation_state(self, state: SimulationState):
"""
Ghi state xuống disk VÀ cập nhật vào RAM cache đồng thời.
Đây là cơ chế "write-through cache":
- Mỗi khi state thay đổi, cả RAM lẫn file đều được cập nhật ngay lập tức.
- Đảm bảo: nếu server crash giữa chừng, state.json vẫn phản ánh trạng thái mới nhất.
Tự động cập nhật updated_at thành thời điểm hiện tại trước khi ghi.
"""
sim_dir = self._get_simulation_dir(state.simulation_id)
state_file = os.path.join(sim_dir, "state.json")
# Stamp thời gian ghi cuối cùng
state.updated_at = datetime.now().isoformat()
# Ghi JSON ra đĩa (ensure_ascii=False để Unicode/tiếng Việt không bị escape)
with open(state_file, 'w', encoding='utf-8') as f:
json.dump(state.to_dict(), f, ensure_ascii=False, indent=2)
# Cập nhật đồng thời vào RAM cache
self._simulations[state.simulation_id] = state
def _load_simulation_state(self, simulation_id: str) -> Optional[SimulationState]:
"""
Đọc state của một simulation — ưu tiên dùng RAM cache, fallback về disk.
Chiến lược "cache-aside":
1. Kiểm tra RAM cache trước (nhanh, không I/O)
2. Nếu không có trong RAM → tìm file state.json trên disk
3. Nếu tìm thấy → deserialize thành SimulationState object, nạp vào RAM cache
4. Nếu không tìm thấy file → trả về None
Trường hợp None: simulation_id không hợp lệ hoặc chưa từng được tạo.
"""
# --- Bước 1: Kiểm tra RAM cache ---
if simulation_id in self._simulations:
return self._simulations[simulation_id]
# --- Bước 2: Tìm trên disk ---
sim_dir = self._get_simulation_dir(simulation_id)
state_file = os.path.join(sim_dir, "state.json")
if not os.path.exists(state_file):
return None # Simulation này chưa từng tồn tại
# --- Bước 3: Deserialize từ JSON thành dataclass ---
with open(state_file, 'r', encoding='utf-8') as f:
data = json.load(f)
# Khôi phục từng field, dùng .get() với giá trị mặc định để tương thích ngược
# khi có field mới được thêm vào SimulationState sau này
state = SimulationState(
simulation_id=simulation_id,
project_id=data.get("project_id", ""),
graph_id=data.get("graph_id", ""),
enable_twitter=data.get("enable_twitter", True),
enable_reddit=data.get("enable_reddit", True),
status=SimulationStatus(data.get("status", "created")), # string → Enum
entities_count=data.get("entities_count", 0),
profiles_count=data.get("profiles_count", 0),
entity_types=data.get("entity_types", []),
config_generated=data.get("config_generated", False),
config_reasoning=data.get("config_reasoning", ""),
current_round=data.get("current_round", 0),
twitter_status=data.get("twitter_status", "not_started"),
reddit_status=data.get("reddit_status", "not_started"),
created_at=data.get("created_at", datetime.now().isoformat()),
updated_at=data.get("updated_at", datetime.now().isoformat()),
error=data.get("error"),
)
# --- Bước 4: Nạp vào RAM cache để lần sau không cần đọc file nữa ---
self._simulations[simulation_id] = state
return state
# --------------------------------------------------------------------------
# PUBLIC: create_simulation — Khởi tạo một simulation mới (trạng thái CREATED)
# --------------------------------------------------------------------------
def create_simulation(
self,
project_id: str,
graph_id: str,
enable_twitter: bool = True,
enable_reddit: bool = True,
) -> SimulationState:
"""
Tạo một simulation mới với trạng thái ban đầu CREATED.
Chỉ tạo metadata — KHÔNG đọc entity, KHÔNG gọi LLM.
Sau khi gọi hàm này, cần gọi tiếp prepare_simulation() để chuẩn bị dữ liệu.
Args:
project_id: Mã ID của Project (Quản lý cấp đầu vào)
graph_id: Đồ thị ID tương ứng lấy bên Zep — quyết định kho Entity nào được dùng
enable_twitter: Có sinh profile và chạy mô phỏng trên Twitter không?
enable_reddit: Có sinh profile và chạy mô phỏng trên Reddit không?
Returns:
SimulationState với status=CREATED, chưa có entity/profile/config.
"""
import uuid
# Tạo ID ngắn gọn, dễ đọc: "sim_" + 12 ký tự hex ngẫu nhiên
# Ví dụ: "sim_a3f8c21b004e" — đủ ngẫu nhiên để tránh va chạm trong phạm vi project
simulation_id = f"sim_{uuid.uuid4().hex[:12]}"
# Khởi tạo object state với giá trị mặc định
state = SimulationState(
simulation_id=simulation_id,
project_id=project_id,
graph_id=graph_id,
enable_twitter=enable_twitter,
enable_reddit=enable_reddit,
status=SimulationStatus.CREATED,
)
# Ghi ra disk ngay lập tức để đảm bảo bền vững
self._save_simulation_state(state)
logger.info(f"Created simulation: {simulation_id}, project={project_id}, graph={graph_id}")
return state
# --------------------------------------------------------------------------
# PUBLIC: prepare_simulation — Pipeline chuẩn bị dữ liệu (3 giai đoạn chính)
# --------------------------------------------------------------------------
def prepare_simulation(
self,
simulation_id: str,
simulation_requirement: str,
document_text: str,
defined_entity_types: Optional[List[str]] = None,
use_llm_for_profiles: bool = True,
progress_callback: Optional[callable] = None,
parallel_profile_count: int = 3
) -> SimulationState:
"""
Giai đoạn chuẩn bị dữ liệu tạo giả lập mô phỏng (Tiến trình Automation 100%)
Hàm này chạy trong một background thread (được Flask gọi không đồng bộ).
Progress được báo cáo liên tục về frontend qua progress_callback.
3 giai đoạn chính:
┌─────────────────────────────────────────────────────────────────────────┐
│ Giai đoạn 1: ĐỌC ENTITY từ Zep Graph │
│ ZepEntityReader → lọc theo defined_entity_types → FilteredEntities │
│ Nếu không có entity nào → FAILED (không thể tiếp tục) │
├─────────────────────────────────────────────────────────────────────────┤
│ Giai đoạn 2: SINH AGENT PROFILE │
│ OasisProfileGenerator → gọi LLM song song (ThreadPoolExecutor) │
│ → reddit_profiles.json + twitter_profiles.csv │
├─────────────────────────────────────────────────────────────────────────┤
│ Giai đoạn 3: SINH SIMULATION CONFIG │
│ SimulationConfigGenerator → LLM phân tích → simulation_config.json │
└─────────────────────────────────────────────────────────────────────────┘
Args:
simulation_id: ID của simulation đã tạo bằng create_simulation()
simulation_requirement: Yêu cầu mô phỏng từ người dùng (gửi cho LLM sinh config)
document_text: Nội dung văn bản gốc (ngữ cảnh để LLM hiểu chủ đề đang mô phỏng)
defined_entity_types: Danh sách loại entity cần lọc (None = lấy tất cả)
use_llm_for_profiles: True = gọi LLM để làm giàu persona; False = dùng rule-based
progress_callback: Hàm nhận (stage: str, percent: int, message: str, **kwargs)
parallel_profile_count: Số thread LLM chạy song song khi sinh profile (mặc định 3)
Returns:
SimulationState với status=READY (thành công) hoặc status=FAILED (thất bại)
Raises:
ValueError: Nếu simulation_id không tồn tại
Exception: Re-raise mọi lỗi sau khi đã cập nhật state sang FAILED
"""
# Load state hiện tại — phải tồn tại trước (đã gọi create_simulation())
state = self._load_simulation_state(simulation_id)
if not state:
raise ValueError(f"Giả lập với ID: {simulation_id} không tồn tại")
try:
# --- Chuyển trạng thái sang PREPARING ngay từ đầu ---
# Quan trọng: ghi xuống disk trước để nếu crash sau đó,
# frontend không bị thấy state "CREATED" mãi mãi
state.status = SimulationStatus.PREPARING
self._save_simulation_state(state)
# Lấy đường dẫn thư mục để ghi các file output
sim_dir = self._get_simulation_dir(simulation_id)
# ==========================================================================
# GIAI ĐOẠN 1: Kết nối Zep và lọc Entity
# ==========================================================================
# ZepEntityReader đọc TẤT CẢ nodes trong graph (có thể hàng nghìn nodes),
# sau đó lọc chỉ giữ lại các node có label nằm trong defined_entity_types.
# Kết quả trả về là FilteredEntities chứa danh sách EntityNode đã enrich.
# Retry: 3 lần với exponential backoff (2s → 4s → 8s).
# ==========================================================================
if progress_callback:
progress_callback("reading", 0, "Connecting to Zep Graph data store...")
reader = ZepEntityReader()
if progress_callback:
progress_callback("reading", 30, "Extracting Node data from graph...")
# filter_defined_entities: đọc nodes → lọc theo label → enrich với edges
# enrich_with_edges=True → mỗi EntityNode sẽ có related_edges & related_nodes
# (cần thiết để LLM hiểu mối quan hệ giữa các entity khi sinh persona)
filtered = reader.filter_defined_entities(
graph_id=state.graph_id,
defined_entity_types=defined_entity_types,
enrich_with_edges=True
)
# Ghi số liệu thống kê vào state
state.entities_count = filtered.filtered_count
state.entity_types = list(filtered.entity_types)
if progress_callback:
progress_callback(
"reading", 100,
f"Extraction complete, filtered entity count: {filtered.filtered_count} empty entities",
current=filtered.filtered_count,
total=filtered.filtered_count
)
# --- Early exit: không có entity nào → không thể chạy simulation ---
# Lý do có thể: graph chưa được populate, hoặc defined_entity_types quá hẹp
if filtered.filtered_count == 0:
state.status = SimulationStatus.FAILED
state.error = "No valid entities extracted for simulation. Please check if the Graph was generated properly with valid text."
self._save_simulation_state(state)
return state # Trả về sớm, không raise exception
# ==========================================================================
# GIAI ĐOẠN 2: Sinh Agent Profile cho từng Entity
# ==========================================================================
# Mỗi EntityNode → 1 OasisAgentProfile (có user_id, bio, persona, mbti, ...)
# Nếu use_llm=True: gọi LLM để viết bio/persona phong phú hơn
# Nếu use_llm=False: dùng rule-based (nhanh hơn, ít tốn token hơn)
# Chạy parallel_profile_count threads đồng thời để tăng tốc.
# ==========================================================================
total_entities = len(filtered.entities)
if progress_callback:
progress_callback(
"generating_profiles", 0,
"Ready for AI Generation process...",
current=0,
total=total_entities
)
# Khởi tạo generator — truyền graph_id để nó có thể search Zep thêm nếu cần
generator = OasisProfileGenerator(graph_id=state.graph_id)
# Wrapper chuyển đổi format callback:
# generate_profiles_from_entities gọi: callback(current_count, total, entity_name)
# Nhưng prepare_simulation cần format: callback(stage, percent, message, ...)
def profile_progress(current, total, msg):
if progress_callback:
progress_callback(
"generating_profiles",
int(current / total * 100), # Tính phần trăm hoàn thành
msg,
current=current,
total=total,
item_name=msg
)
# --- Xác định đường dẫn real-time output ---
# Real-time output: trong khi LLM đang sinh profile, file được ghi dần dần
# (không đợi hết rồi mới ghi một lần) → frontend có thể theo dõi tiến độ
# Ưu tiên Reddit vì đó là format JSON chuẩn; Twitter dùng CSV (chỉ khi Reddit tắt)
realtime_output_path = None
realtime_platform = "reddit"
if state.enable_reddit:
realtime_output_path = os.path.join(sim_dir, "reddit_profiles.json")
realtime_platform = "reddit"
elif state.enable_twitter:
# Chỉ đến đây nếu Reddit bị tắt hẳn
realtime_output_path = os.path.join(sim_dir, "twitter_profiles.csv")
realtime_platform = "twitter"
# --- Xác định platform metadata (để tính cost API) ---
# "parallel" = cả hai nền tảng đều bật → profiles được dùng cho cả hai
if state.enable_twitter and state.enable_reddit:
runtime_platform = "parallel"
elif state.enable_twitter:
runtime_platform = "twitter"
elif state.enable_reddit:
runtime_platform = "reddit"
else:
runtime_platform = None # Không nên xảy ra trong thực tế
# Gọi hàm sinh profile chính (blocking — chờ tất cả thread hoàn thành)
profiles = generator.generate_profiles_from_entities(
entities=filtered.entities,
use_llm=use_llm_for_profiles,
progress_callback=profile_progress,
graph_id=state.graph_id, # Để tìm kiếm trong Zep Search Index nếu cần
parallel_count=parallel_profile_count, # Số thread LLM chạy đồng thời
realtime_output_path=realtime_output_path, # Ghi từng profile vừa xong ngay
output_platform=realtime_platform, # Định dạng file (json/csv)
metadata_platform=runtime_platform, # Cho logging cost
simulation_id=simulation_id,
project_id=state.project_id,
)
state.profiles_count = len(profiles)
# --- Lưu lần cuối toàn bộ profiles ra file ---
# (real-time output ở trên có thể bị thiếu nếu một thread crash)
# Đây là lần ghi đảm bảo file hoàn chỉnh 100%
if progress_callback:
progress_callback(
"generating_profiles", 95,
"Compressing Profile data...",
current=total_entities,
total=total_entities
)
if state.enable_reddit:
# Reddit: JSON array các object profile (cấu trúc OASIS yêu cầu)
generator.save_profiles(
profiles=profiles,
file_path=os.path.join(sim_dir, "reddit_profiles.json"),
platform="reddit"
)
if state.enable_twitter:
# Twitter: CSV với header row (cấu trúc OASIS gốc yêu cầu)
generator.save_profiles(
profiles=profiles,
file_path=os.path.join(sim_dir, "twitter_profiles.csv"),
platform="twitter"
)
if progress_callback:
progress_callback(
"generating_profiles", 100,
f"Done, created {len(profiles)} Profiles",
current=len(profiles),
total=len(profiles)
)
# ==========================================================================
# GIAI ĐOẠN 3: Sinh simulation_config.json bằng LLM
# ==========================================================================
# SimulationConfigGenerator gọi LLM với:
# - simulation_requirement (yêu cầu của user)
# - document_text (văn bản nguồn)
# - entities (danh sách entity)
# LLM trả về SimulationParameters gồm: time_config, event_config, agent_configs
# Toàn bộ được serialize thành JSON và ghi ra simulation_config.json.
# File này sau đó được đọc bởi run_parallel_simulation.py khi OASIS chạy.
# ==========================================================================
if progress_callback:
progress_callback(
"generating_config", 0,
"Analyzing input requirements...",
current=0,
total=3
)
config_generator = SimulationConfigGenerator()
if progress_callback:
progress_callback(
"generating_config", 30,
"LLM Bot is generating configuration...",
current=1,
total=3
)
# generate_config: gọi LLM tạo time_config → event_config → agent_configs
# (3 lần gọi LLM riêng biệt, mỗi lần có retry với exponential backoff)
sim_params = config_generator.generate_config(
simulation_id=simulation_id,
project_id=state.project_id,
graph_id=state.graph_id,
simulation_requirement=simulation_requirement,
document_text=document_text,
entities=filtered.entities,
enable_twitter=state.enable_twitter,
enable_reddit=state.enable_reddit
)
if progress_callback:
progress_callback(
"generating_config", 70,
"Saving Config parameters...",
current=2,
total=3
)
# Ghi simulation_config.json ra thư mục của simulation
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()) # to_json() đã xử lý serialize Enum, nested dict
# Đánh dấu config đã sinh xong và lưu lý do LLM giải thích
state.config_generated = True
state.config_reasoning = sim_params.generation_reasoning
if progress_callback:
progress_callback(
"generating_config", 100,
"Configuration Generation complete",
current=3,
total=3
)
# ==========================================================================
# LƯU Ý KIẾN TRÚC QUAN TRỌNG:
# Scripts OASIS (run_parallel_simulation.py, v.v.) vẫn để GỐC tại
# `backend/scripts/` — KHÔNG copy sang thư mục simulation.
# Khi SimulationRunner.start() chạy, nó sẽ gọi thẳng script gốc và
# truyền --config <config_path> để script đọc đúng dữ liệu simulation.
# Lý do: tránh code duplication, dễ update script mà không ảnh hưởng
# các simulation đã chuẩn bị sẵn.
# ==========================================================================
# --- Chuyển trạng thái sang READY — pipeline hoàn tất ---
state.status = SimulationStatus.READY
self._save_simulation_state(state)
logger.info(f"Finished simulation preparation phase for ID: {simulation_id}, "
f"Total entities={state.entities_count}, Created profiles={state.profiles_count}")
return state
except Exception as e:
# --- Xử lý lỗi toàn cục: bất kỳ exception nào cũng → FAILED ---
# Ghi lại traceback đầy đủ để debug, sau đó re-raise để Flask xử lý tiếp
logger.error(f"Error occurred during Simulation preparation (Sim ID: {simulation_id}), ERROR CODE: {str(e)}")
import traceback
logger.error(traceback.format_exc())
# Cập nhật state FAILED với message lỗi để frontend hiển thị
state.status = SimulationStatus.FAILED
state.error = str(e)
self._save_simulation_state(state)
raise # Re-raise để Flask API handler nhận và trả HTTP 500
# --------------------------------------------------------------------------
# PUBLIC UTILITIES — Các hàm đọc/truy vấn trạng thái
# --------------------------------------------------------------------------
def get_simulation(self, simulation_id: str) -> Optional[SimulationState]:
"""
Lấy trạng thái hiện tại của một simulation.
Dùng cache-aside (RAM trước, disk fallback) nên rất nhanh.
Trả về None nếu simulation_id không tồn tại.
"""
return self._load_simulation_state(simulation_id)
def list_simulations(self, project_id: Optional[str] = None) -> List[SimulationState]:
"""
Liệt kê tất cả simulations, có thể lọc theo project_id.
Duyệt qua toàn bộ thư mục con trong SIMULATION_DATA_DIR,
load state.json của từng cái, rồi lọc theo project_id nếu có yêu cầu.
Args:
project_id: Nếu None → trả về tất cả; nếu có → chỉ trả về của project đó
Returns:
Danh sách SimulationState (có thể rỗng nếu chưa có simulation nào)
"""
simulations = []
if os.path.exists(self.SIMULATION_DATA_DIR):
for sim_id in os.listdir(self.SIMULATION_DATA_DIR):
# Bỏ qua các file ẩn (ví dụ: .DS_Store của macOS, .gitkeep)
# và các entry không phải thư mục (ví dụ: file rác nào đó)
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:
# Lọc theo project_id nếu được chỉ định
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]]:
"""
Đọc và trả về danh sách agent profiles đã sinh cho một simulation.
Chỉ hoạt động sau khi prepare_simulation() hoàn thành (status=READY trở lên).
Trả về [] nếu file chưa tồn tại (simulation chưa READY hoặc nền tảng bị tắt).
Args:
simulation_id: ID của simulation
platform: "reddit" → đọc reddit_profiles.json | "twitter" → đọc twitter_profiles.json
(Twitter thực ra lưu .csv nhưng hàm này chỉ dùng cho Reddit JSON)
Returns:
List của các dict profile (mỗi dict là 1 agent)
"""
state = self._load_simulation_state(simulation_id)
if not state:
raise ValueError(f"Simulation with ID {simulation_id} does not exist")
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 [] # File chưa được sinh, trả về rỗng thay vì raise lỗi
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]]:
"""
Đọc và trả về nội dung simulation_config.json của một simulation.
Trả về None nếu config chưa được sinh (chưa qua giai đoạn 3 của prepare_simulation).
Dữ liệu trả về là dict Python (đã parse JSON), không phải string.
"""
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]:
"""
Tạo ra các câu lệnh terminal để chạy simulation này thủ công.
Hữu ích khi developer muốn chạy OASIS trực tiếp ngoài Flask,
hoặc để debug từng nền tảng riêng lẻ.
Returns:
Dict gồm:
- simulation_dir: thư mục chứa dữ liệu simulation
- scripts_dir: thư mục chứa các script OASIS
- config_file: đường dẫn đầy đủ đến simulation_config.json
- commands: dict các lệnh python để chạy từng loại
- instructions: chuỗi hướng dẫn dạng đọc được cho con người
"""
sim_dir = self._get_simulation_dir(simulation_id)
config_path = os.path.join(sim_dir, "simulation_config.json")
# Tính đường dẫn tuyệt đối đến thư mục scripts từ vị trí file này
# __file__ = .../backend/app/services/simulation_manager.py
# → abspath("../../scripts") = .../backend/scripts/
scripts_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '../../scripts'))
return {
"simulation_dir": sim_dir,
"scripts_dir": scripts_dir,
"config_file": config_path,
"commands": {
"twitter": f"python {scripts_dir}/run_twitter_simulation.py --config {config_path}",
"reddit": f"python {scripts_dir}/run_reddit_simulation.py --config {config_path}",
"parallel": f"python {scripts_dir}/run_parallel_simulation.py --config {config_path}",
},
"instructions": (
f"1. Khởi động môi trường môi trường lập trình Conda (nếu có): conda activate MiroFish\n"
f"2. Bắt đầu Run giả lập (Scripts gốc được gọi ra tại {scripts_dir}):\n"
f" - Nếu muốn chỉ giả lập trên Twitter: python {scripts_dir}/run_twitter_simulation.py --config {config_path}\n"
f" - Nếu muốn chỉ giả lập trên Reddit: python {scripts_dir}/run_reddit_simulation.py --config {config_path}\n"
f" - Chạy giả lập cả hai phân luồng song song: python {scripts_dir}/run_parallel_simulation.py --config {config_path}"
)
}