MicroFish/backend/app/services/simulation_config_generator.py

1308 lines
65 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Trình tạo cấu hình Simulation tự động bằng LLM
Vị trí trong pipeline:
─────────────────────────────────────────────────────────────────────────────
simulation_manager.prepare_simulation() [Giai đoạn 3 — bước cuối cùng]
└─ SimulationConfigGenerator.generate_config()
├─ _generate_time_config() [LLM → bao nhiêu giờ, giờ nào cao điểm]
├─ _generate_event_config() [LLM → hot topics, initial posts]
├─ _generate_agent_configs_batch() × N [LLM → hành vi từng agent]
└─ _assign_initial_post_agents() [ghép poster_type → agent_id]
─────────────────────────────────────────────────────────────────────────────
Input: List[EntityNode] từ ZepEntityReader + document_text + simulation_requirement
Output: SimulationParameters → ghi ra simulation_config.json
(File này sau đó được đọc bởi run_parallel_simulation.py khi OASIS chạy)
Chiến lược LLM chia từng bước:
Thay vì gửi toàn bộ dữ liệu 1 lần (dễ bị token limit), chia thành 4 bước nhỏ:
1. Time config — context cắt còn 10,000 ký tự
2. Event config — context cắt còn 8,000 ký tự
3. Agent configs — chia batch 15 agent/lần (N batch)
4. Platform config — hardcoded (không cần LLM)
"""
import json
import math
from typing import Dict, Any, List, Optional, Callable
from dataclasses import dataclass, field, asdict
from datetime import datetime
from openai import OpenAI
from ..config import Config
from ..utils.logger import get_logger
from ..utils.llm_cost import create_tracked_chat_completion
from .zep_entity_reader import EntityNode, ZepEntityReader
logger = get_logger('mirofish.simulation_config')
# ==============================================================================
# HẰNG SỐ: CHINA_TIMEZONE_CONFIG — Mẫu hoạt động theo múi giờ UTC+7
# ==============================================================================
# Lưu ý tên biến: được kế thừa từ codebase OASIS gốc của Trung Quốc (UTC+8).
# Thực tế trong MiroFish, hệ thống đã chuyển sang dùng múi giờ Việt Nam (UTC+7) —
# các prompt LLM đều chỉ định "người Việt Nam / giờ Hà Nội".
# Biến này hiện chỉ dùng để tham khảo/documentation, KHÔNG được import trực tiếp
# vào các hàm tạo config (logic thực tế nằm trong prompt LLM và rule fallback).
# ==============================================================================
CHINA_TIMEZONE_CONFIG = {
# Khung giờ khuya (Hầu như không có hoạt động)
"dead_hours": [0, 1, 2, 3, 4, 5],
# Khung giờ sáng (Dần thức dậy)
"morning_hours": [6, 7, 8],
# Khung giờ làm việc
"work_hours": [9, 10, 11, 12, 13, 14, 15, 16, 17, 18],
# Khung giờ cao điểm buổi tối (Hoạt động mạnh nhất)
"peak_hours": [19, 20, 21, 22],
# Khung giờ ban đêm (Hoạt động giảm sút)
"night_hours": [23],
# Hệ số hoạt động tương ứng với mỗi thời điểm
"activity_multipliers": {
"dead": 0.05, # Gần như không có ai lúc rạng sáng
"morning": 0.4, # Sáng sớm bắt đầu dần sôi động
"work": 0.7, # Mức trung bình trong giờ làm việc
"peak": 1.5, # Cao điểm tối
"night": 0.5 # Giảm sút đêm khuya
}
}
# ==============================================================================
# DATACLASS: AgentActivityConfig — Cấu hình hành vi của một agent trong OASIS
# ==============================================================================
# Mỗi AgentActivityConfig tương ứng với 1 EntityNode đã được map thành agent.
# OASIS đọc các field này để quyết định:
# - Agent này có "thức dậy" trong round này không? (activity_level)
# - Nếu thức, nó làm gì? (posts_per_hour, comments_per_hour, stance)
# - Bài của nó được bao nhiêu agent khác nhìn thấy? (influence_weight)
# ==============================================================================
@dataclass
class AgentActivityConfig:
"""Cấu hình hoạt động cho một Agent"""
# --- Định danh ---
agent_id: int # Phải khớp với user_id trong reddit_profiles.json / twitter_profiles.csv
entity_uuid: str # UUID trong Zep — dùng để trace ngược lại nguồn gốc
entity_name: str # Tên hiển thị (ví dụ: "Trần Văn An")
entity_type: str # Loại entity (ví dụ: "Student", "MediaOutlet")
# --- Mức độ hoạt động tổng thể ---
# 0.0 = không bao giờ hoạt động; 1.0 = luôn hoạt động mỗi round
# OASIS dùng giá trị này nhân với activity_multiplier của giờ hiện tại
# để tính xác suất agent được chọn trong round.
activity_level: float = 0.5
# --- Tần suất phát ngôn ---
# Số lần trung bình agent đăng bài mới / bình luận trong 1 giờ mô phỏng
posts_per_hour: float = 1.0
comments_per_hour: float = 2.0
# --- Khoảng thời gian hoạt động (hệ 24 giờ, 023) ---
# Agent chỉ có thể được kích hoạt trong các giờ này.
# Ví dụ: student=[8,9,10,18,19,20,21,22,23], university=[9,10,...,17]
active_hours: List[int] = field(default_factory=lambda: list(range(8, 23)))
# --- Tốc độ phản hồi ---
# Độ trễ (phút mô phỏng) trước khi agent phản ứng với sự kiện nóng.
# Nhỏ = phản ứng nhanh (sinh viên: 1-15 phút)
# Lớn = phản ứng chậm (cơ quan chính phủ: 60-240 phút)
response_delay_min: int = 5
response_delay_max: int = 60
# --- Khuynh hướng cảm xúc ---
# -1.0 = rất tiêu cực (phản đối mạnh mẽ)
# 0.0 = trung lập
# +1.0 = rất tích cực (ủng hộ nhiệt tình)
sentiment_bias: float = 0.0
# --- Lập trường với chủ đề mô phỏng ---
# "supportive": ủng hộ quyết định/sự kiện được mô phỏng
# "opposing": phản đối
# "neutral": không rõ ràng, phân tích khách quan
# "observer": chỉ theo dõi, ít tham gia tranh luận
stance: str = "neutral"
# --- Trọng số ảnh hưởng ---
# Mức độ bài đăng của agent này xuất hiện trên "timeline" của agent khác.
# Cao = lan rộng hơn (mediaoutlet: 2.5, university: 3.0)
# Thấp = ít người thấy (student: 0.8)
influence_weight: float = 1.0
# ==============================================================================
# DATACLASS: TimeSimulationConfig — Cấu hình thời gian và nhịp hoạt động
# ==============================================================================
# Định nghĩa "lịch sinh hoạt" của thế giới mô phỏng:
# - Tổng độ dài mô phỏng (total_simulation_hours)
# - Mỗi round đại diện cho bao nhiêu phút thực (minutes_per_round)
# - Khung giờ nào sẽ có nhiều hay ít agent hoạt động (peak/off_peak/morning/work)
#
# OASIS đọc các multiplier để tính số agent active mỗi round:
# active_count = random(min, max) × multiplier[current_hour]
# ==============================================================================
@dataclass
class TimeSimulationConfig:
"""Cấu hình thời gian mô phỏng"""
# Tổng số giờ mô phỏng — 72 giờ = 3 ngày thực
# (Với minutes_per_round=60, tổng số round = 72)
total_simulation_hours: int = 72
# Mỗi round đại diện cho bao nhiêu phút trong thế giới thực
# 60 = mỗi round là 1 giờ (khuyến nghị để cân bằng tốc độ vs độ chi tiết)
minutes_per_round: int = 60
# Số agent được kích hoạt mỗi giờ — LLM chọn giá trị trong [min, max]
# dựa trên quy mô simulation (số entity)
agents_per_hour_min: int = 5
agents_per_hour_max: int = 20
# Khung giờ cao điểm: 19-22 giờ — đông nhất, nhân với 1.5
peak_hours: List[int] = field(default_factory=lambda: [19, 20, 21, 22])
peak_activity_multiplier: float = 1.5
# Khung giờ chết: 0-5 giờ sáng — gần như không ai online, nhân với 0.05
off_peak_hours: List[int] = field(default_factory=lambda: [0, 1, 2, 3, 4, 5])
off_peak_activity_multiplier: float = 0.05
# Buổi sáng: 6-8 giờ — hoạt động tăng dần, nhân với 0.4
morning_hours: List[int] = field(default_factory=lambda: [6, 7, 8])
morning_activity_multiplier: float = 0.4
# Giờ làm việc: 9-18 giờ — hoạt động ổn định, nhân với 0.7
work_hours: List[int] = field(default_factory=lambda: [9, 10, 11, 12, 13, 14, 15, 16, 17, 18])
work_activity_multiplier: float = 0.7
# ==============================================================================
# DATACLASS: EventConfig — Cấu hình "ngòi nổ" khởi động cuộc mô phỏng
# ==============================================================================
# initial_posts là các bài đăng đầu tiên được post ngay khi simulation bắt đầu.
# Chúng tạo ra "sự kiện kích hoạt" để các agent khác phản ứng.
#
# Sau khi LLM sinh initial_posts (có poster_type), hàm _assign_initial_post_agents()
# sẽ map poster_type → agent_id thực để OASIS biết agent nào post bài đó.
# ==============================================================================
@dataclass
class EventConfig:
"""Cấu hình sự kiện cho Simulation gồm initial_posts, scheduled_events, hot_topics, narrative_direction"""
# Bài đăng khởi đầu — post ngay round 1, tạo "tin nóng" để agent phản ứng
# Mỗi phần tử: {"content": "...", "poster_type": "MediaOutlet", "poster_agent_id": 12}
# poster_agent_id được gán sau bởi _assign_initial_post_agents()
initial_posts: List[Dict[str, Any]] = field(default_factory=list)
# Sự kiện lập lịch — chưa được implement, dành cho tính năng tương lai
scheduled_events: List[Dict[str, Any]] = field(default_factory=list)
# Từ khóa chủ đề nóng — OASIS dùng để tăng khả năng agent chú ý đến chủ đề này
# Ví dụ: ["học phí", "biểu tình", "giáo dục"]
hot_topics: List[str] = field(default_factory=list)
# Hướng dẫn tổng quan diễn biến dư luận — LLM viết ra để định hướng
# Ví dụ: "Thông báo tăng học phí → sinh viên phản đối → leo thang thành phong trào..."
narrative_direction: str = ""
# ==============================================================================
# DATACLASS: PlatformConfig — Cấu hình riêng cho từng nền tảng mạng xã hội
# ==============================================================================
# Twitter và Reddit có thuật toán feed khác nhau → cần cấu hình riêng.
# Các giá trị này được OASIS dùng khi quyết định bài nào hiển thị trên timeline agent.
#
# recency_weight + popularity_weight + relevance_weight = 1.0 (không bắt buộc, nhưng
# nên cộng lại = 1 để tránh scale lệch)
# ==============================================================================
@dataclass
class PlatformConfig:
"""Cấu hình đặc thù dành riêng cho các nền tảng gồm platform, recency_weight, popularity_weight, relevance_weight, viral_threshold và echo_chamber_strength"""
platform: str # "twitter" hoặc "reddit"
# Ba trọng số trong thuật toán đề xuất nội dung:
recency_weight: float = 0.4 # Ưu tiên bài mới (Twitter: cao hơn Reddit)
popularity_weight: float = 0.3 # Ưu tiên bài nhiều like/repost
relevance_weight: float = 0.3 # Ưu tiên bài liên quan đến sở thích agent
# Ngưỡng lan truyền "viral":
# Twitter: 10 — dễ lan hơn (retweet lan nhanh)
# Reddit: 15 — khó lan hơn (cần nhiều upvote hơn)
# Khi bài đạt ngưỡng này, OASIS tăng xác suất nó xuất hiện trên feed của nhiều agent
viral_threshold: int = 10
# Hiệu ứng "buồng phản âm" (echo chamber):
# 0.0 = không có — agent thấy đa chiều
# 1.0 = tối đa — agent chỉ thấy bài cùng quan điểm
# Reddit thường cao hơn (0.6) vì cơ chế subreddit tạo bong bóng thông tin
echo_chamber_strength: float = 0.5
# ==============================================================================
# DATACLASS: SimulationParameters — Object tổng hợp toàn bộ cấu hình simulation
# ==============================================================================
# Đây là output cuối cùng của SimulationConfigGenerator.generate_config().
# Nó được serialize thành simulation_config.json — file trung tâm mà:
# 1. Flask đọc để điền metadata vào state.json
# 2. OASIS scripts đọc để khởi tạo môi trường và chạy simulation
#
# to_json() đảm bảo Unicode (tiếng Việt) không bị escape thành \uXXXX
# ==============================================================================
@dataclass
class SimulationParameters:
"""Bộ tham số cấu hình đầy đủ cho một lượt Simulation"""
# --- Định danh (bắt buộc khi tạo object) ---
simulation_id: str
project_id: str
graph_id: str
simulation_requirement: str # Yêu cầu gốc từ người dùng — được lưu để traceback
# --- Các cấu hình con (có default để tạo object không cần truyền hết) ---
time_config: TimeSimulationConfig = field(default_factory=TimeSimulationConfig)
agent_configs: List[AgentActivityConfig] = field(default_factory=list)
event_config: EventConfig = field(default_factory=EventConfig)
# None nếu platform tương ứng bị tắt (enable_twitter=False / enable_reddit=False)
twitter_config: Optional[PlatformConfig] = None
reddit_config: Optional[PlatformConfig] = None
# --- Metadata LLM ---
llm_model: str = "" # Tên model đã dùng để gen (dùng để debug khi chất lượng kém)
llm_base_url: str = "" # Base URL API (có thể là OpenAI, Azure, local...)
# --- Metadata tạo ---
generated_at: str = field(default_factory=lambda: datetime.now().isoformat())
# Chuỗi reasoning nối từ tất cả các bước: "Time config: ...|Event config: ...|Agent: ..."
generation_reasoning: str = ""
def to_dict(self) -> Dict[str, Any]:
"""Serialize toàn bộ object thành dict — dùng để truyền nội bộ."""
time_dict = asdict(self.time_config)
return {
"simulation_id": self.simulation_id,
"project_id": self.project_id,
"graph_id": self.graph_id,
"simulation_requirement": self.simulation_requirement,
"time_config": time_dict,
"agent_configs": [asdict(a) for a in self.agent_configs],
"event_config": asdict(self.event_config),
"twitter_config": asdict(self.twitter_config) if self.twitter_config else None,
"reddit_config": asdict(self.reddit_config) if self.reddit_config else None,
"llm_model": self.llm_model,
"llm_base_url": self.llm_base_url,
"generated_at": self.generated_at,
"generation_reasoning": self.generation_reasoning,
}
def to_json(self, indent: int = 2) -> str:
"""Serialize thành chuỗi JSON — ensure_ascii=False để giữ nguyên tiếng Việt."""
return json.dumps(self.to_dict(), ensure_ascii=False, indent=indent)
# ==============================================================================
# CLASS: SimulationConfigGenerator — Trình điều phối sinh config bằng LLM
# ==============================================================================
# Được gọi bởi: SimulationManager.prepare_simulation() ở Giai đoạn 3.
# Input đến từ: ZepEntityReader.filter_defined_entities() (danh sách EntityNode)
#
# Sơ đồ luồng gọi LLM trong generate_config():
#
# generate_config()
# │
# ├── Bước 1: _generate_time_config() ← 1 LLM call
# │ └── _parse_time_config() ← validate + parse
# │
# ├── Bước 2: _generate_event_config() ← 1 LLM call
# │ └── _parse_event_config() ← parse đơn giản
# │
# ├── Bước 3..N: _generate_agent_configs_batch() ← 1 LLM call × N batch
# │ └── _generate_agent_config_by_rule() ← fallback nếu LLM fail/thiếu
# │
# ├── _assign_initial_post_agents() ← không cần LLM, map bằng alias
# │
# └── Bước cuối: PlatformConfig hardcoded ← không cần LLM
#
# Mỗi LLM call đi qua _call_llm_with_retry() với retry 3 lần, temperature giảm dần.
# ==============================================================================
class SimulationConfigGenerator:
"""
Trình tạo cấu hình Simulation tự động bằng LLM
Sử dụng LLM phân tích yêu cầu mô phỏng, nội dung tài liệu, Entity từ đồ thị,
tự động xây dựng các thông số cấu trúc tối ưu cho đợt Simulation.
Chiến lược chia từng bước (step-by-step generation):
1. Tạo cấu hình thời gian và cấu hình Event (Nhẹ, chạy nhanh)
2. Phân nhỏ đợt tạo cấu hình cho Agent (15 agent mỗi đợt)
3. Tạo cấu hình nền tảng (hardcoded, không cần LLM)
"""
MAX_CONTEXT_LENGTH = 50000 # Giới hạn tổng ngữ cảnh gửi cho LLM (ký tự)
AGENTS_PER_BATCH = 15 # Số agent xử lý mỗi lần gọi LLM (tránh token limit)
# Độ dài context cho từng bước — cắt ngắn để không waste token
TIME_CONFIG_CONTEXT_LENGTH = 10000 # Bước 1: chỉ cần biết chủ đề + entity types
EVENT_CONFIG_CONTEXT_LENGTH = 8000 # Bước 2: cần biết thêm về các entity điển hình
ENTITY_SUMMARY_LENGTH = 300 # Tóm tắt mỗi entity trong context
AGENT_SUMMARY_LENGTH = 300 # Tóm tắt entity trong prompt sinh agent config
ENTITIES_PER_TYPE_DISPLAY = 20 # Tối đa bao nhiêu entity/loại trong context
def __init__(
self,
api_key: Optional[str] = None,
base_url: Optional[str] = None,
model_name: Optional[str] = None
):
self.api_key = api_key or Config.LLM_API_KEY
self.base_url = base_url or Config.LLM_BASE_URL
self.model_name = model_name or Config.LLM_MODEL_NAME
if not self.api_key:
raise ValueError("LLM_API_KEY has not been configured")
self.client = OpenAI(
api_key=self.api_key,
base_url=self.base_url
)
# Metadata runtime được inject vào mỗi LLM call để theo dõi cost/usage
self._runtime_metadata: Dict[str, Any] = {}
def generate_config(
self,
simulation_id: str,
project_id: str,
graph_id: str,
simulation_requirement: str,
document_text: str,
entities: List[EntityNode],
enable_twitter: bool = True,
enable_reddit: bool = True,
progress_callback: Optional[Callable[[int, int, str], None]] = None,
) -> SimulationParameters:
"""
Pipeline sinh cấu hình simulation hoàn chỉnh theo từng bước.
Tổng quan luồng:
┌───────────────────────────────────────────────────────────────┐
│ Bước 1: _generate_time_config() → TimeSimulationConfig │
│ Bước 2: _generate_event_config() → EventConfig + initial_posts│
│ Bước 3..N: _generate_agent_configs_batch() × ceil(N/15) │
│ _assign_initial_post_agents() → gán poster_agent_id │
│ Bước cuối: PlatformConfig hardcoded │
└───────────────────────────────────────────────────────────────┘
Args:
simulation_id: ID của simulation (dùng để điền vào SimulationParameters)
project_id: ID project
graph_id: ID Zep Graph (dùng để điền metadata, không query thêm ở đây)
simulation_requirement: Yêu cầu người dùng (truyền thẳng vào LLM prompt)
document_text: Văn bản tài liệu gốc (làm ngữ cảnh nền tảng cho LLM)
entities: Danh sách EntityNode từ ZepEntityReader (đã lọc + enrich)
enable_twitter: True → sinh twitter_config
enable_reddit: True → sinh reddit_config
progress_callback: Hàm nhận (current_step, total_steps, message: str)
Returns:
SimulationParameters đầy đủ — gọi .to_json() để ghi ra file.
"""
logger.info(f"Start generating simulation configuration: simulation_id={simulation_id}, entity_count={len(entities)}")
self._runtime_metadata = {
"simulation_id": simulation_id,
"project_id": project_id,
"component": "simulation_config_generator",
"phase": "prepare_simulation_config",
}
# Tính tổng số bước để progress_callback hiển thị đúng phần trăm
# total_steps = 1 (time) + 1 (event) + N_batch (agents) + 1 (platform) = 3 + N_batch
num_batches = math.ceil(len(entities) / self.AGENTS_PER_BATCH)
total_steps = 3 + num_batches
current_step = 0
def report_progress(step: int, message: str):
nonlocal current_step
current_step = step
if progress_callback:
progress_callback(step, total_steps, message)
logger.info(f"[{step}/{total_steps}] {message}")
# Xây dựng context chung (tối đa 50,000 ký tự) dùng cho tất cả các bước
# Bao gồm: simulation_requirement + entity summary + document_text (phần còn lại)
context = self._build_context(
simulation_requirement=simulation_requirement,
document_text=document_text,
entities=entities
)
# Mảng tích lũy reasoning từ mỗi bước — nối lại cuối để lưu vào SimulationParameters
reasoning_parts = []
# ========== Bước 1: Cấu hình Thời gian ==========
report_progress(1, "Generating time configuration...")
num_entities = len(entities)
time_config_result = self._generate_time_config(context, num_entities)
time_config = self._parse_time_config(time_config_result, num_entities)
reasoning_parts.append(f"Time config reasoning: {time_config_result.get('reasoning', 'Success')}")
# ========== Bước 2: Cấu hình Sự kiện ==========
report_progress(2, "Generating event configuration and hot topics...")
event_config_result = self._generate_event_config(context, simulation_requirement, entities)
event_config = self._parse_event_config(event_config_result)
reasoning_parts.append(f"Event config reasoning: {event_config_result.get('reasoning', 'Success')}")
# ========== Bước 3..N: Cấu hình Agent (chia batch) ==========
# Ví dụ: 47 entity → batch1(0-14) + batch2(15-29) + batch3(30-44) + batch4(45-46)
all_agent_configs = []
for batch_idx in range(num_batches):
start_idx = batch_idx * self.AGENTS_PER_BATCH
end_idx = min(start_idx + self.AGENTS_PER_BATCH, len(entities))
batch_entities = entities[start_idx:end_idx]
report_progress(
3 + batch_idx,
f"Generating agent configuration ({start_idx + 1}-{end_idx}/{len(entities)})..."
)
batch_configs = self._generate_agent_configs_batch(
context=context,
entities=batch_entities,
start_idx=start_idx,
simulation_requirement=simulation_requirement
)
all_agent_configs.extend(batch_configs)
reasoning_parts.append(f"Agent config reasoning: Successfully generated {len(all_agent_configs)} agents")
# ========== Gán agent cho initial posts ==========
# Sau khi có đủ all_agent_configs, map poster_type → agent_id thực
logger.info("Assigning poster agents for initial posts...")
event_config = self._assign_initial_post_agents(event_config, all_agent_configs)
assigned_count = len([p for p in event_config.initial_posts if p.get("poster_agent_id") is not None])
reasoning_parts.append(f"Initial post assignment: {assigned_count} posts have been assigned to publishers")
# ========== Bước cuối: Platform Config (hardcoded) ==========
# Không cần LLM — các giá trị này là hằng số đã được tuning thực nghiệm
report_progress(total_steps, "Generating platform configuration...")
twitter_config = None
reddit_config = None
if enable_twitter:
twitter_config = PlatformConfig(
platform="twitter",
recency_weight=0.4, # Twitter ưu tiên bài mới hơn Reddit
popularity_weight=0.3,
relevance_weight=0.3,
viral_threshold=10, # Dễ lan hơn Reddit
echo_chamber_strength=0.5
)
if enable_reddit:
reddit_config = PlatformConfig(
platform="reddit",
recency_weight=0.3, # Reddit cân bằng hơn giữa mới vs phổ biến
popularity_weight=0.4, # Reddit ưu tiên upvote hơn thời gian
relevance_weight=0.3,
viral_threshold=15, # Ngưỡng cao hơn → khó "bùng" hơn Twitter
echo_chamber_strength=0.6 # Reddit có subreddit → buồng phản âm mạnh hơn
)
# Gộp tất cả vào SimulationParameters
params = SimulationParameters(
simulation_id=simulation_id,
project_id=project_id,
graph_id=graph_id,
simulation_requirement=simulation_requirement,
time_config=time_config,
agent_configs=all_agent_configs,
event_config=event_config,
twitter_config=twitter_config,
reddit_config=reddit_config,
llm_model=self.model_name,
llm_base_url=self.base_url,
generation_reasoning=" | ".join(reasoning_parts)
)
logger.info(f"Simulation configuration generation complete: {len(params.agent_configs)} agent configs created")
return params
def _build_context(
self,
simulation_requirement: str,
document_text: str,
entities: List[EntityNode]
) -> str:
"""
Tổng hợp ngữ cảnh cho LLM — tối đa MAX_CONTEXT_LENGTH ký tự.
Ưu tiên (thứ tự giảm dần):
1. simulation_requirement — giữ nguyên 100%, không cắt
2. entity_summary — tóm tắt nhóm theo loại, tối đa ENTITIES_PER_TYPE_DISPLAY/loại
3. document_text — phần còn lại sau khi đã dùng cho 1+2
Document_text bị cắt cuối nếu tổng vượt quá 50,000 ký tự.
"""
entity_summary = self._summarize_entities(entities)
context_parts = [
f"## Simulation Requirements\n{simulation_requirement}",
f"\n## Entity Information ({len(entities)} entities)\n{entity_summary}",
]
current_length = sum(len(p) for p in context_parts)
# Dành 500 ký tự buffer để tránh off-by-one khi LLM đếm token
remaining_length = self.MAX_CONTEXT_LENGTH - current_length - 500
if remaining_length > 0 and document_text:
doc_text = document_text[:remaining_length]
if len(document_text) > remaining_length:
doc_text += "\n...(Document Truncated)" # note: việc cắt bớt đi có bị ảnh hưởng gì nghiêm trọng không? nếu để full thì có tốn context không?
context_parts.append(f"\n## Original Document Content\n{doc_text}")
return "\n".join(context_parts)
def _summarize_entities(self, entities: List[EntityNode]) -> str:
"""
Tạo chuỗi tóm tắt entity theo nhóm loại — dùng trong _build_context().
Format output:
### Student (5 entity)
- Trần Văn An: Sinh viên năm 3 ngành CNTT...
- Nguyễn Thị B: ...
### University (2 entity)
- Đại học X: ...
"""
lines = []
# Nhóm entity theo loại để LLM dễ nắm phân phối nhân vật
by_type: Dict[str, List[EntityNode]] = {}
for e in entities:
t = e.get_entity_type() or "Unknown"
if t not in by_type:
by_type[t] = []
by_type[t].append(e)
for entity_type, type_entities in by_type.items():
lines.append(f"\n### {entity_type} ({len(type_entities)} entity)")
display_count = self.ENTITIES_PER_TYPE_DISPLAY
summary_len = self.ENTITY_SUMMARY_LENGTH
for e in type_entities[:display_count]:
summary_preview = (e.summary[:summary_len] + "...") if len(e.summary) > summary_len else e.summary
lines.append(f"- {e.name}: {summary_preview}")
if len(type_entities) > display_count:
lines.append(f" ... and {len(type_entities) - display_count} more entities")
return "\n".join(lines)
'''
### Student (5 entity)
- Trần Văn An: Sinh viên năm 3 ngành CNTT tại Đại học X...
- Nguyễn Thị B: Sinh viên năm 2, hoạt động phong trào...
### University (1 entity)
- Đại học X: Trường đại học công lập lớn tại Hà Nội...
### MediaOutlet (2 entity)
- VnExpress: Báo điện tử lớn nhất Việt Nam...
'''
def _call_llm_with_retry(self, prompt: str, system_prompt: str) -> Dict[str, Any]:
"""
Gọi LLM và retry tối đa 3 lần với temperature giảm dần.
Chiến lược retry:
Attempt 1: temperature=0.7 (sáng tạo, đa dạng hơn)
Attempt 2: temperature=0.6 + sleep 2s (nếu attempt 1 fail)
Attempt 3: temperature=0.5 + sleep 4s (nếu attempt 2 fail)
Temperature giảm dần → output ổn định hơn, ít hallucination → dễ parse JSON hơn.
Nếu finish_reason="length" (bị cắt do token limit) → _fix_truncated_json() trước khi parse.
Nếu json.loads() lỗi → _try_fix_config_json() để cố sửa.
Nếu cả 3 lần đều fail → raise exception để caller dùng hardcode fallback.
"""
import re
max_attempts = 3
last_error = None
for attempt in range(max_attempts):
try:
response = create_tracked_chat_completion(
client=self.client,
model=self.model_name,
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": prompt}
],
response_format={"type": "json_object"},
temperature=0.7 - (attempt * 0.1), # 0.7 → 0.6 → 0.5
metadata=self._runtime_metadata,
)
content = response.choices[0].message.content
finish_reason = response.choices[0].finish_reason
# Nếu bị cắt giữa chừng do max_tokens → thêm ngoặc đóng trước khi parse
if finish_reason == 'length':
logger.warning(f"LLM output was truncated (attempt {attempt+1})")
content = self._fix_truncated_json(content)
try:
return json.loads(content)
except json.JSONDecodeError as e:
logger.warning(f"Failed to parse JSON (attempt {attempt+1}): {str(e)[:80]}")
# Thử sửa JSON trước khi bỏ cuộc
fixed = self._try_fix_config_json(content)
if fixed:
return fixed
last_error = e
except Exception as e:
logger.warning(f"Failed to call LLM (attempt {attempt+1}): {str(e)[:80]}")
last_error = e
import time
time.sleep(2 * (attempt + 1)) # Sleep 2s → 4s → (không có attempt 3+)
raise last_error or Exception("LLM connection completely failed")
def _fix_truncated_json(self, content: str) -> str:
"""
Đóng các ngoặc JSON bị bỏ ngỏ khi LLM bị cắt giữa chừng.
Thuật toán:
1. Đếm số { chưa có } đóng tương ứng → thêm } vào cuối
2. Đếm số [ chưa có ] đóng tương ứng → thêm ] vào cuối
3. Nếu ký tự cuối là dở dang (không phải ",}]) → thêm " để đóng string
Ví dụ input bị cắt:
'{"agent_configs": [{"agent_id": 0, "stance": "oppos'
Sau fix:
'{"agent_configs": [{"agent_id": 0, "stance": "oppos"}]}'
"""
content = content.strip()
open_braces = content.count('{') - content.count('}')
open_brackets = content.count('[') - content.count(']')
# Đóng string bị bỏ ngỏ trước khi đóng object/array
if content and content[-1] not in '",}]':
content += '"'
content += ']' * open_brackets
content += '}' * open_braces
return content
def _try_fix_config_json(self, content: str) -> Optional[Dict[str, Any]]:
"""
Cố sửa JSON bị lỗi format bằng chuỗi bước:
Bước 1: Gọi _fix_truncated_json() (đóng bracket thiếu)
Bước 2: Regex tìm khối {...} lớn nhất trong chuỗi (loại bỏ text thừa trước/sau)
Bước 3: Clean newline trong string values (LLM hay nhét \n trong string JSON)
Bước 4: Thử json.loads() — nếu OK trả về luôn
Bước 5: Xóa control characters (0x00-0x1F, 0x7F-0x9F) gây lỗi parse
Bước 6: Chuẩn hóa whitespace thừa
Bước 7: Thử json.loads() lần nữa — nếu vẫn fail trả về None
Trả về None nếu không thể sửa → caller sẽ retry hoặc dùng fallback.
"""
import re
content = self._fix_truncated_json(content)
json_match = re.search(r'\{[\s\S]*\}', content)
if json_match:
json_str = json_match.group()
# Clean newline và whitespace thừa bên trong string values
def fix_string(match):
s = match.group(0)
s = s.replace('\n', ' ').replace('\r', ' ')
s = re.sub(r'\s+', ' ', s)
return s
json_str = re.sub(r'"[^"\\]*(?:\\.[^"\\]*)*"', fix_string, json_str)
try:
return json.loads(json_str)
except:
# Xóa control characters và thử lần nữa
json_str = re.sub(r'[\x00-\x1f\x7f-\x9f]', ' ', json_str)
json_str = re.sub(r'\s+', ' ', json_str)
try:
return json.loads(json_str)
except:
pass
return None
# --------------------------------------------------------------------------
# BƯỚC 1: Sinh cấu hình Thời gian
# --------------------------------------------------------------------------
def _generate_time_config(self, context: str, num_entities: int) -> Dict[str, Any]:
"""
Gọi LLM để sinh cấu hình thời gian mô phỏng.
LLM nhận context cắt còn TIME_CONFIG_CONTEXT_LENGTH (10,000 ký tự) và trả về:
{
"total_simulation_hours": 72,
"minutes_per_round": 60,
"agents_per_hour_min": 5,
"agents_per_hour_max": 30,
"peak_hours": [19, 20, 21, 22],
"off_peak_hours": [0, 1, 2, 3, 4, 5],
"morning_hours": [6, 7, 8],
"work_hours": [9, 10, ..., 18],
"reasoning": "Giải thích tại sao chọn các thông số này"
}
Ràng buộc gửi cho LLM:
- Người dùng là người Việt Nam → giờ Hà Nội (UTC+7)
- agents_per_hour tối đa = 90% số entity (max_agents_allowed = num_entities × 0.9)
Fallback (LLM fail): _get_default_time_config() — hardcoded defaults.
"""
context_truncated = context[:self.TIME_CONFIG_CONTEXT_LENGTH]
max_agents_allowed = max(1, int(num_entities * 0.9))
prompt = f"""Dựa trên các yêu cầu mô phỏng dưới đây, hãy tạo cấu hình mô phỏng thời gian.
{context_truncated}
## Nhiệm vụ
Hãy tạo JSON cấu hình thời gian.
### Nguyên tắc cơ bản (Chỉ mang tính chất tham khảo, cần điều chỉnh linh hoạt theo sự kiện cụ thể và nhóm đối tượng tham gia):
- Nhóm người dùng là người Việt Nam, cần phù hợp với thói quen sinh hoạt theo giờ Hà Nội.
- 0-5 giờ sáng: Hầu như không có hoạt động (Hệ số hoạt động: 0.05).
- 6-8 giờ sáng: Hoạt động tăng dần (Hệ số hoạt động: 0.4).
- 9-18 giờ (Giờ làm việc): Hoạt động trung bình (Hệ số hoạt động: 0.7).
- 19-22 giờ tối: Giai đoạn cao điểm (Hệ số hoạt động: 1.5).
- Sau 23 giờ: Mức độ hoạt động giảm xuống (Hệ số hoạt động: 0.5).
- Quy luật chung: Thấp vào rạng sáng, tăng dần vào buổi sáng, trung bình trong giờ làm việc và cao điểm vào buổi tối.
- **Quan trọng**: Các giá trị ví dụ dưới đây chỉ mang tính chất tham khảo, bạn cần điều chỉnh các khung giờ cụ thể dựa trên tính chất sự kiện và đặc điểm của nhóm đối tượng tham gia.
- Ví dụ: Cao điểm của nhóm sinh viên có thể là 21-23 giờ; Nhóm truyền thông hoạt động cả ngày; Các cơ quan chính thống chỉ hoạt động trong giờ hành chính.
- Ví dụ: Tin tức nóng hổi (hotspot) có thể dẫn đến thảo luận vào đêm muộn; off_peak_hours có thể được rút ngắn tương ứng.
### Định dạng JSON trả về (Không sử dụng markdown)
Ví dụ Format như sau:
{{
"total_simulation_hours": 72,
"minutes_per_round": 60,
"agents_per_hour_min": 5,
"agents_per_hour_max": 50,
"peak_hours": [19, 20, 21, 22],
"off_peak_hours": [0, 1, 2, 3, 4, 5],
"morning_hours": [6, 7, 8],
"work_hours": [9, 10, 11, 12, 13, 14, 15, 16, 17, 18],
"reasoning": "Giải thích cấu hình thời gian cho sự kiện này."
}}
Mô tả các trường:
- total_simulation_hours (int): Tổng thời gian mô phỏng, từ 24-168 giờ. Ngắn cho sự kiện đột xuất, dài cho các chủ đề kéo dài.
- minutes_per_round (int): Thời gian mỗi hiệp, 30-120 phút, khuyến nghị 60 phút.
- agents_per_hour_min (int): Số lượng Agent kích hoạt tối thiểu mỗi giờ (Phạm vi: 1-{max_agents_allowed}).
- agents_per_hour_max (int): Số lượng Agent kích hoạt tối đa mỗi giờ (Phạm vi: 1-{max_agents_allowed}).
- peak_hours (int array): Khung giờ cao điểm, điều chỉnh theo nhóm tham gia.
- off_peak_hours (int array): Khung giờ thấp điểm, thường là đêm khuya rạng sáng.
- morning_hours (mảng int): Khung giờ buổi sáng.
- work_hours (mảng int): Khung giờ làm việc.
- reasoning (string): Giải thích ngắn gọn lý do tại sao cấu hình như vậy."""
system_prompt = "Bạn là chuyên gia mô phỏng mạng xã hội. Trả về định dạng JSON thuần túy; cấu hình thời gian cần phù hợp với thói quen sinh hoạt của người Việt Nam."
try:
return self._call_llm_with_retry(prompt, system_prompt)
except Exception as e:
logger.warning(f"Failed to generate Time Config through LLM {e}. Returning the basic default rules...")
return self._get_default_time_config(num_entities)
def _get_default_time_config(self, num_entities: int) -> Dict[str, Any]:
"""
Fallback hardcoded khi LLM fail hoàn toàn.
agents_per_hour được tính theo công thức:
min = num_entities // 15 (ví dụ: 47 entity → min=3)
max = num_entities // 5 (ví dụ: 47 entity → max=9)
Đảm bảo không vượt quá tổng số agent thực tế.
"""
return {
"total_simulation_hours": 72,
"minutes_per_round": 60,
"agents_per_hour_min": max(1, num_entities // 15),
"agents_per_hour_max": max(5, num_entities // 5),
"peak_hours": [19, 20, 21, 22],
"off_peak_hours": [0, 1, 2, 3, 4, 5],
"morning_hours": [6, 7, 8],
"work_hours": [9, 10, 11, 12, 13, 14, 15, 16, 17, 18],
"reasoning": "Defaults to Vietnamese users' daily routines and working hours (1 hour/round)"
}
def _parse_time_config(self, result: Dict[str, Any], num_entities: int) -> TimeSimulationConfig:
"""
Parse dict từ LLM thành TimeSimulationConfig, kèm validation.
Validation quan trọng: đảm bảo agents_per_hour không vượt tổng số agent thực tế.
Nếu LLM trả về agents_per_hour_max=100 nhưng chỉ có 47 entity,
OASIS sẽ cố chọn 100 agent nhưng không đủ → lỗi.
Logic điều chỉnh:
agents_per_hour_min > num_entities → reset về num_entities // 10
agents_per_hour_max > num_entities → reset về num_entities // 2
min >= max → reset min về max // 2
"""
agents_per_hour_min = result.get("agents_per_hour_min", max(1, num_entities // 15))
agents_per_hour_max = result.get("agents_per_hour_max", max(5, num_entities // 5))
if agents_per_hour_min > num_entities:
logger.warning(f"agents_per_hour_min ({agents_per_hour_min}) exceeds total number of Agents ({num_entities}), corrected.")
agents_per_hour_min = max(1, num_entities // 10)
if agents_per_hour_max > num_entities:
logger.warning(f"agents_per_hour_max ({agents_per_hour_max}) exceeds total number of Agents ({num_entities}), corrected.")
agents_per_hour_max = max(agents_per_hour_min + 1, num_entities // 2)
if agents_per_hour_min >= agents_per_hour_max:
agents_per_hour_min = max(1, agents_per_hour_max // 2)
logger.warning(f"agents_per_hour_min >= max, modified to {agents_per_hour_min}")
return TimeSimulationConfig(
total_simulation_hours=result.get("total_simulation_hours", 72),
minutes_per_round=result.get("minutes_per_round", 60),
agents_per_hour_min=agents_per_hour_min,
agents_per_hour_max=agents_per_hour_max,
peak_hours=result.get("peak_hours", [19, 20, 21, 22]),
off_peak_hours=result.get("off_peak_hours", [0, 1, 2, 3, 4, 5]),
off_peak_activity_multiplier=0.05,
morning_hours=result.get("morning_hours", [6, 7, 8]),
morning_activity_multiplier=0.4,
work_hours=result.get("work_hours", list(range(9, 19))),
work_activity_multiplier=0.7,
peak_activity_multiplier=1.5
)
# --------------------------------------------------------------------------
# BƯỚC 2: Sinh cấu hình Sự kiện
# --------------------------------------------------------------------------
def _generate_event_config(
self,
context: str,
simulation_requirement: str,
entities: List[EntityNode]
) -> Dict[str, Any]:
"""
Gọi LLM để sinh các bài đăng khởi động và hướng phát triển dư luận.
Ràng buộc quan trọng gửi cho LLM: poster_type phải thuộc danh sách
entity types thực sự có trong simulation. LLM được cung cấp danh sách
type_examples (ví dụ điển hình của mỗi loại) để chọn đúng.
Ví dụ output:
{
"hot_topics": ["học phí", "biểu tình"],
"narrative_direction": "Thông báo tăng học phí → làn sóng phản đối...",
"initial_posts": [
{"content": "CHÍNH THỨC: Đại học X tăng học phí...", "poster_type": "University"},
{"content": "Không thể chấp nhận được! #HọcPhíTăng", "poster_type": "Student"}
]
}
Sau bước này, _assign_initial_post_agents() sẽ gán poster_agent_id thực.
Fallback khi LLM fail: hot_topics=[], initial_posts=[] — simulation vẫn chạy
nhưng không có "ngòi nổ" → agent tự tạo bài đăng theo activity_level.
"""
# Thu thập danh sách loại thực thể có thực để LLM chọn poster_type đúng
entity_types_available = list(set(
e.get_entity_type() or "Unknown" for e in entities
))
# Ví dụ điển hình mỗi loại (tối đa 3) — giúp LLM biết tên thật của entity
type_examples = {}
for e in entities:
etype = e.get_entity_type() or "Unknown"
if etype not in type_examples:
type_examples[etype] = []
if len(type_examples[etype]) < 3:
type_examples[etype].append(e.name)
type_info = "\n".join([
f"- {t}: {', '.join(examples)}"
for t, examples in type_examples.items()
])
context_truncated = context[:self.EVENT_CONFIG_CONTEXT_LENGTH]
prompt = f"""Dựa trên các yêu cầu mô phỏng sau đây, hãy tạo cấu hình sự kiện.
Yêu cầu mô phỏng: {simulation_requirement}
{context_truncated}
## Các loại thực thể khả dụng và ví dụ
{type_info}
## Nhiệm vụ
Vui lòng tạo JSON cấu hình sự kiện:
- Trích xuất các từ khóa chủ đề nóng (hot topics).
- Mô tả hướng phát triển của dư luận.
- Thiết kế nội dung các bài đăng khởi tạo, **mỗi bài đăng phải chỉ định poster_type (loại người đăng)**.
**QUAN TRỌNG**: poster_type phải được chọn từ "Các loại thực thể khả dụng" ở trên để các bài đăng khởi tạo có thể được phân bổ cho đúng Agent phù hợp.
Ví dụ: Các tuyên bố chính thức nên được đăng bởi loại Official/University, tin tức bởi MediaOutlet, và quan điểm sinh viên bởi Student.
Trả về định dạng JSON (không sử dụng markdown):
{{
"hot_topics": ["Từ khóa 1", "Từ khóa 2", ...],
"narrative_direction": "<Mô tả hướng phát triển dư luận>",
"initial_posts": [
{{"content": "Nội dung bài đăng...", "poster_type": "Loại thực thể (phải chọn từ các loại khả dụng)"}},
...
],
"reasoning": "<Giải thích ngắn gọn>"
}}"""
system_prompt = "Bạn là chuyên gia phân tích dư luận. Trả về định dạng JSON thuần túy. Lưu ý rằng poster_type phải khớp chính xác với các loại thực thể khả dụng."
try:
return self._call_llm_with_retry(prompt, system_prompt)
except Exception as e:
logger.warning(f"Failed to load LLM Event configurations: {e}, using default configs instead.")
return {
"hot_topics": [],
"narrative_direction": "",
"initial_posts": [],
"reasoning": "Sử dụng Config mặc định do LLM lỗi"
}
def _parse_event_config(self, result: Dict[str, Any]) -> EventConfig:
"""Parse dict từ LLM thành EventConfig. Đơn giản — không có validation phức tạp."""
return EventConfig(
initial_posts=result.get("initial_posts", []),
scheduled_events=[], # Chưa implement — dành cho tính năng tương lai
hot_topics=result.get("hot_topics", []),
narrative_direction=result.get("narrative_direction", "")
)
def _assign_initial_post_agents(
self,
event_config: EventConfig,
agent_configs: List[AgentActivityConfig]
) -> EventConfig:
"""
Gán agent_id thực cho mỗi initial_post dựa trên poster_type.
3 tầng matching (ưu tiên từ cao đến thấp):
┌─────────────────────────────────────────────────────────────────┐
│ Tầng 1: Direct match │
│ poster_type.lower() khớp chính xác key trong agents_by_type │
│ Ví dụ: "student" → agents_by_type["student"][0] │
├─────────────────────────────────────────────────────────────────┤
│ Tầng 2: Alias match │
│ poster_type nằm trong danh sách alias của một key │
│ Ví dụ: "media" → alias của "mediaoutlet" → dùng mediaoutlet │
│ Cho phép LLM dùng tên biến thể mà vẫn map được đúng │
├─────────────────────────────────────────────────────────────────┤
│ Tầng 3: Influence fallback │
│ Không tìm được → lấy agent có influence_weight cao nhất │
│ (Thường là mediaoutlet hoặc university) │
└─────────────────────────────────────────────────────────────────┘
Lưu ý used_indices: mỗi loại có counter riêng để tránh dùng cùng 1 agent
nhiều lần khi có nhiều post cùng poster_type.
Ví dụ: 3 post "Student" → agent 0, 1, 2 (thay vì 0, 0, 0).
"""
if not event_config.initial_posts:
return event_config
# Index agents theo loại để tra cứu nhanh
agents_by_type: Dict[str, List[AgentActivityConfig]] = {}
for agent in agent_configs:
etype = agent.entity_type.lower()
if etype not in agents_by_type:
agents_by_type[etype] = []
agents_by_type[etype].append(agent)
# Bảng alias — LLM đôi khi dùng tên khác nhau cho cùng một loại
type_aliases = {
"official": ["official", "university", "governmentagency", "government"],
"university": ["university", "official"],
"mediaoutlet": ["mediaoutlet", "media"],
"student": ["student", "person"],
"professor": ["professor", "expert", "teacher"],
"alumni": ["alumni", "person"],
"organization": ["organization", "ngo", "company", "group"],
"person": ["person", "student", "alumni"],
}
# Dùng round-robin trong mỗi loại (modulo len) để phân bổ đều
used_indices: Dict[str, int] = {}
updated_posts = []
for post in event_config.initial_posts:
poster_type = post.get("poster_type", "").lower()
content = post.get("content", "")
matched_agent_id = None
# Tầng 1: Direct match
if poster_type in agents_by_type:
agents = agents_by_type[poster_type]
idx = used_indices.get(poster_type, 0) % len(agents)
matched_agent_id = agents[idx].agent_id
used_indices[poster_type] = idx + 1
else:
# Tầng 2: Alias match
for alias_key, aliases in type_aliases.items():
if poster_type in aliases or alias_key == poster_type:
for alias in aliases:
if alias in agents_by_type:
agents = agents_by_type[alias]
idx = used_indices.get(alias, 0) % len(agents)
matched_agent_id = agents[idx].agent_id
used_indices[alias] = idx + 1
break
if matched_agent_id is not None:
break
# Tầng 3: Influence fallback
if matched_agent_id is None:
logger.warning(f"Could not find matching Agent type '{poster_type}', assigning to highest influence Agent instead")
if agent_configs:
sorted_agents = sorted(agent_configs, key=lambda a: a.influence_weight, reverse=True)
matched_agent_id = sorted_agents[0].agent_id
else:
matched_agent_id = 0
updated_posts.append({
"content": content,
"poster_type": post.get("poster_type", "Unknown"),
"poster_agent_id": matched_agent_id
})
logger.info(f"Initial post assignment: poster_type='{poster_type}' -> agent_id={matched_agent_id}")
event_config.initial_posts = updated_posts
return event_config
# --------------------------------------------------------------------------
# BƯỚC 3..N: Sinh cấu hình Agent (chia batch)
# --------------------------------------------------------------------------
def _generate_agent_configs_batch(
self,
context: str,
entities: List[EntityNode],
start_idx: int,
simulation_requirement: str
) -> List[AgentActivityConfig]:
"""
Gọi LLM để sinh cấu hình hoạt động cho một batch agents (tối đa AGENTS_PER_BATCH=15).
Lý do chia batch: nếu có 50+ entity, gửi tất cả 1 lần sẽ vượt token limit
và LLM có xu hướng bỏ sót entity cuối. Chia 15 agent/lần đảm bảo đủ.
Mỗi entity trong batch được format thành:
{"agent_id": 5, "entity_name": "Trần Văn An", "entity_type": "Student", "summary": "..."}
LLM trả về dict với key "agent_configs": [...]
→ extract theo agent_id, map vào AgentActivityConfig object.
→ Nếu LLM bỏ sót agent nào → _generate_agent_config_by_rule() bù vào.
agent_id trong output phải khớp chính xác với start_idx + i
(để OASIS map đúng với user_id trong profile file).
"""
entity_list = []
summary_len = self.AGENT_SUMMARY_LENGTH
for i, e in enumerate(entities):
entity_list.append({
"agent_id": start_idx + i,
"entity_name": e.name,
"entity_type": e.get_entity_type() or "Unknown",
"summary": e.summary[:summary_len] if e.summary else ""
})
prompt = f"""Dựa trên các thông tin sau đây, hãy tạo cấu hình hoạt động trên mạng xã hội cho từng thực thể.
Yêu cầu mô phỏng: {simulation_requirement}
## Danh sách thực thể
```json
{json.dumps(entity_list, ensure_ascii=False, indent=2)}
```
## Nhiệm vụ
Tạo cấu hình hoạt động cho từng thực thể, lưu ý:
- **Thời gian phù hợp với thói quen của người Việt Nam**: Gần như không hoạt động từ 0-5 giờ sáng, hoạt động mạnh nhất từ 19-22 giờ tối.
- **Cơ quan chính thống (University/GovernmentAgency)**: Hoạt động thấp (0.1-0.3), hoạt động trong giờ làm việc (9:00-17:00), phản hồi chậm (60-240 phút), tầm ảnh hưởng cao (2.5-3.0).
- **Truyền thông (MediaOutlet)**: Hoạt động trung bình (0.4-0.6), hoạt động cả ngày (8:00-23:00), phản hồi nhanh (5-30 phút), tầm ảnh hưởng cao (2.0-2.5).
- **Cá nhân (Student/Person/Alumni)**: Hoạt động cao (0.6-0.9), hoạt động chủ yếu vào buổi tối (18:00-23:00), phản hồi nhanh (1-15 phút), tầm ảnh hưởng thấp (0.8-1.2).
- **Người công chúng/Chuyên gia**: Hoạt động trung bình (0.4-0.6), tầm ảnh hưởng trung bình cao (1.5-2.0).
Trả về định dạng JSON (không sử dụng markdown):
{{
"agent_configs": [
{{
"agent_id": <Phải khớp chính xác với đầu vào>,
"activity_level": <0.0-1.0>,
"posts_per_hour": <Tần suất đăng bài>,
"comments_per_hour": <Tần suất bình luận>,
"active_hours": [<Danh sách giờ hoạt động, cân nhắc thói quen người Việt Nam>],
"response_delay_min": <Độ trễ phản hồi tối thiểu tính bằng phút>,
"response_delay_max": <Độ trễ phản hồi tối đa tính bằng phút>,
"sentiment_bias": <-1.0 đến 1.0>,
"stance": "<supportive/opposing/neutral/observer>",
"influence_weight": <Trọng số ảnh hưởng>
}},
...
]
}}"""
system_prompt = "Bạn là chuyên gia phân tích hành vi mạng xã hội. Trả về JSON thuần túy. Cấu hình phải phù hợp với thói quen sinh hoạt của người Việt Nam."
try:
result = self._call_llm_with_retry(prompt, system_prompt)
# Index theo agent_id để tra cứu O(1) khi fill vào từng entity
llm_configs = {cfg["agent_id"]: cfg for cfg in result.get("agent_configs", [])}
except Exception as e:
logger.warning(f"Failed LLM generating Agent batch configs: {e}, falling back to default manual rules.")
llm_configs = {}
# Tạo AgentActivityConfig cho mỗi entity trong batch
configs = []
for i, entity in enumerate(entities):
agent_id = start_idx + i
cfg = llm_configs.get(agent_id, {})
# Nếu LLM bỏ sót agent này → dùng rule-based fallback
if not cfg:
cfg = self._generate_agent_config_by_rule(entity)
config = AgentActivityConfig(
agent_id=agent_id,
entity_uuid=entity.uuid,
entity_name=entity.name,
entity_type=entity.get_entity_type() or "Unknown",
activity_level=cfg.get("activity_level", 0.5),
posts_per_hour=cfg.get("posts_per_hour", 0.5),
comments_per_hour=cfg.get("comments_per_hour", 1.0),
active_hours=cfg.get("active_hours", list(range(9, 23))),
response_delay_min=cfg.get("response_delay_min", 5),
response_delay_max=cfg.get("response_delay_max", 60),
sentiment_bias=cfg.get("sentiment_bias", 0.0),
stance=cfg.get("stance", "neutral"),
influence_weight=cfg.get("influence_weight", 1.0)
)
configs.append(config)
return configs
def _generate_agent_config_by_rule(self, entity: EntityNode) -> Dict[str, Any]:
"""
Sinh cấu hình agent bằng rule cứng khi LLM fail hoặc bỏ sót entity này.
Rule được xây dựng dựa trên hành vi thực tế trên mạng xã hội Việt Nam:
┌──────────────────────┬──────────┬──────────────────┬───────────┬───────────┐
│ Loại entity │ activity │ active_hours │ delay(ph) │ influence │
├──────────────────────┼──────────┼──────────────────┼───────────┼───────────┤
│ university/gov/ngo │ 0.2 │ 9-17 (hành chính)│ 60-240 │ 3.0 │
│ mediaoutlet │ 0.5 │ 7-23 (cả ngày) │ 5-30 │ 2.5 │
│ professor/expert │ 0.4 │ 8-21 │ 15-90 │ 2.0 │
│ student │ 0.8 │ sáng + tối │ 1-15 │ 0.8 │
│ alumni │ 0.6 │ trưa + tối │ 5-30 │ 1.0 │
│ (default) │ 0.7 │ 9-23 │ 2-20 │ 1.0 │
└──────────────────────┴──────────┴──────────────────┴───────────┴───────────┘
"""
entity_type = (entity.get_entity_type() or "Unknown").lower()
if entity_type in ["university", "governmentagency", "ngo"]:
# Cơ quan nhà nước/tổ chức: hoạt động giờ hành chính, phát ngôn ít nhưng ảnh hưởng lớn
return {
"activity_level": 0.2,
"posts_per_hour": 0.1,
"comments_per_hour": 0.05,
"active_hours": list(range(9, 18)),
"response_delay_min": 60,
"response_delay_max": 240,
"sentiment_bias": 0.0,
"stance": "neutral",
"influence_weight": 3.0
}
elif entity_type in ["mediaoutlet"]:
# Báo đài: hoạt động cả ngày, đưa tin nhanh, nhiều người theo dõi
return {
"activity_level": 0.5,
"posts_per_hour": 0.8,
"comments_per_hour": 0.3,
"active_hours": list(range(7, 24)),
"response_delay_min": 5,
"response_delay_max": 30,
"sentiment_bias": 0.0,
"stance": "observer",
"influence_weight": 2.5
}
elif entity_type in ["professor", "expert", "official"]:
# Chuyên gia/giảng viên: phát biểu có chọn lọc, ban ngày và tối sớm
return {
"activity_level": 0.4,
"posts_per_hour": 0.3,
"comments_per_hour": 0.5,
"active_hours": list(range(8, 22)),
"response_delay_min": 15,
"response_delay_max": 90,
"sentiment_bias": 0.0,
"stance": "neutral",
"influence_weight": 2.0
}
elif entity_type in ["student"]:
# Sinh viên: rất tích cực, đặc biệt buổi tối, phản ứng nhanh
return {
"activity_level": 0.8,
"posts_per_hour": 0.6,
"comments_per_hour": 1.5,
"active_hours": [8, 9, 10, 11, 12, 13, 18, 19, 20, 21, 22, 23],
"response_delay_min": 1,
"response_delay_max": 15,
"sentiment_bias": 0.0,
"stance": "neutral",
"influence_weight": 0.8
}
elif entity_type in ["alumni"]:
# Cựu sinh viên: online giờ nghỉ trưa + buổi tối sau giờ làm
return {
"activity_level": 0.6,
"posts_per_hour": 0.4,
"comments_per_hour": 0.8,
"active_hours": [12, 13, 19, 20, 21, 22, 23],
"response_delay_min": 5,
"response_delay_max": 30,
"sentiment_bias": 0.0,
"stance": "neutral",
"influence_weight": 1.0
}
else:
# Default — cư dân mạng thông thường: hoạt động ban ngày + tối
return {
"activity_level": 0.7,
"posts_per_hour": 0.5,
"comments_per_hour": 1.2,
"active_hours": [9, 10, 11, 12, 13, 18, 19, 20, 21, 22, 23],
"response_delay_min": 2,
"response_delay_max": 20,
"sentiment_bias": 0.0,
"stance": "neutral",
"influence_weight": 1.0
}