diff --git a/.gitignore b/.gitignore
index 01a7716e..4a590314 100644
--- a/.gitignore
+++ b/.gitignore
@@ -57,4 +57,5 @@ backend/logs/
backend/uploads/
# Dữ liệu Docker
-data/
\ No newline at end of file
+data/
+uploads_old/
\ No newline at end of file
diff --git a/SIMULATION_PIPELINE_REPORT.md b/SIMULATION_PIPELINE_REPORT.md
new file mode 100644
index 00000000..10134635
--- /dev/null
+++ b/SIMULATION_PIPELINE_REPORT.md
@@ -0,0 +1,1316 @@
+# Báo cáo Kỹ thuật: Pipeline Mô phỏng MiroFish
+
+**Phiên bản:** 1.0
+**Ngày:** 2026-05-04
+**Dự án:** MiroFish — Hệ thống mô phỏng dư luận mạng xã hội dựa trên OASIS Framework
+
+---
+
+## Mục lục
+
+1. [Tổng quan hệ thống](#1-tổng-quan-hệ-thống)
+2. [Kiến trúc tổng thể](#2-kiến-trúc-tổng-thể)
+3. [Giai đoạn 0 — Tiền xử lý tài liệu](#3-giai-đoạn-0--tiền-xử-lý-tài-liệu)
+4. [Giai đoạn 1 — Khởi tạo Simulation](#4-giai-đoạn-1--khởi-tạo-simulation)
+5. [Giai đoạn 2A — Đọc Entity từ Zep Graph](#5-giai-đoạn-2a--đọc-entity-từ-zep-graph)
+6. [Giai đoạn 2B — Sinh Agent Profile](#6-giai-đoạn-2b--sinh-agent-profile)
+7. [Giai đoạn 2C — Sinh Simulation Config](#7-giai-đoạn-2c--sinh-simulation-config)
+8. [Giai đoạn 3 — Chạy Simulation (OASIS)](#8-giai-đoạn-3--chạy-simulation-oasis)
+9. [Giai đoạn 4 — Tạo Report (ReACT Agent)](#9-giai-đoạn-4--tạo-report-react-agent)
+10. [Cơ chế Fallback toàn hệ thống](#10-cơ-chế-fallback-toàn-hệ-thống)
+11. [Cấu trúc file và thư mục](#11-cấu-trúc-file-và-thư-mục)
+12. [Luồng dữ liệu end-to-end](#12-luồng-dữ-liệu-end-to-end)
+
+---
+
+## 1. Tổng quan hệ thống
+
+### 1.1 MiroFish là gì?
+
+MiroFish là một hệ thống mô phỏng dư luận trên mạng xã hội. Ý tưởng cốt lõi là: thay vì quan sát xem điều gì đang xảy ra ngoài đời thực, hệ thống **xây dựng lại một thế giới ảo** có các nhân vật (Agent) giống hệt người thật, rồi "thả" một sự kiện vào trong đó để xem mọi người phản ứng như thế nào.
+
+**Ví dụ thực tế:**
+> Một trường đại học muốn biết: nếu họ tăng học phí 20%, sinh viên, giảng viên, phụ huynh và báo chí sẽ phản ứng ra sao trên mạng xã hội? Thay vì phải thực sự tăng học phí rồi chờ xem hậu quả, MiroFish mô phỏng kịch bản đó trước và dự báo kết quả.
+
+### 1.2 Các thành phần chính
+
+| Thành phần | Vai trò |
+|---|---|
+| **Zep** | Cơ sở dữ liệu đồ thị (Graph Database) chứa thông tin về các thực thể (người, tổ chức, sự kiện) được trích xuất từ tài liệu gốc |
+| **OASIS Framework** | Framework mã nguồn mở chạy mô phỏng mạng xã hội với các Agent AI |
+| **LLM (Large Language Model)** | Mô hình ngôn ngữ lớn (như GPT-4, Gemini...) được dùng để tạo hồ sơ nhân vật, cấu hình mô phỏng và viết báo cáo |
+| **Flask Backend** | Server Python xử lý API, quản lý trạng thái và điều phối toàn bộ pipeline |
+| **Vue.js Frontend** | Giao diện người dùng để upload tài liệu, theo dõi tiến trình và xem báo cáo |
+
+### 1.3 Luồng tổng quát (5 giai đoạn)
+
+```
+[Tài liệu gốc]
+ ↓ (Tiền xử lý)
+[Zep Graph: Entities + Relationships]
+ ↓ (Đọc + Lọc)
+[Danh sách EntityNode]
+ ↓ (Sinh Profile song song)
+[Agent Profiles (reddit_profiles.json / twitter_profiles.csv)]
+ ↓ (LLM sinh Config)
+[simulation_config.json]
+ ↓ (OASIS subprocess)
+[actions.jsonl — Log hành động của từng Agent theo từng Round]
+ ↓ (ReACT Agent viết báo cáo)
+[report.md — Báo cáo dự báo tương lai]
+```
+
+---
+
+## 2. Kiến trúc tổng thể
+
+### 2.1 Sơ đồ các module
+
+```
+backend/
+├── app/
+│ ├── services/
+│ │ ├── text_processor.py # Tiền xử lý văn bản
+│ │ ├── zep_entity_reader.py # Đọc Entity từ Zep Graph
+│ │ ├── oasis_profile_generator.py # Sinh Agent Profile
+│ │ ├── simulation_config_generator.py # Sinh tham số mô phỏng
+│ │ ├── simulation_manager.py # Quản lý vòng đời Simulation
+│ │ ├── simulation_runner.py # Chạy + giám sát Simulation
+│ │ ├── simulation_ipc.py # Giao tiếp Flask ↔ OASIS subprocess
+│ │ └── report_agent.py # Viết báo cáo bằng ReACT
+│ └── utils/
+│ └── llm_cost.py # Theo dõi chi phí LLM
+└── scripts/
+ ├── run_parallel_simulation.py # OASIS chạy cả hai nền tảng
+ ├── run_twitter_simulation.py # OASIS chỉ Twitter
+ ├── run_reddit_simulation.py # OASIS chỉ Reddit
+ └── action_logger.py # Ghi log hành động Agent
+```
+
+### 2.2 Phân tách trách nhiệm
+
+**Flask Backend** (process chính) chịu trách nhiệm:
+- Nhận request từ Frontend
+- Gọi các service theo đúng thứ tự
+- Theo dõi trạng thái và lưu vào file JSON
+- Expose API để Frontend poll tiến độ
+
+**OASIS Scripts** (subprocess độc lập) chịu trách nhiệm:
+- Khởi tạo môi trường mạng xã hội ảo
+- Chạy các vòng (round) mô phỏng
+- Cho từng Agent AI "suy nghĩ" và hành động
+- Ghi log hành động ra file `actions.jsonl`
+
+Hai phần này giao tiếp với nhau qua **hai cơ chế**:
+1. **File-based**: Flask đọc `actions.jsonl` để monitor tiến độ
+2. **IPC (Inter-Process Communication)**: Flask ghi lệnh vào `ipc_commands/`, OASIS poll và trả kết quả vào `ipc_responses/` (dùng cho tính năng Interview agent đang chạy)
+
+---
+
+## 3. Giai đoạn 0 — Tiền xử lý tài liệu
+
+**File:** `backend/app/services/text_processor.py`
+
+### 3.1 Mục đích
+
+Trước khi bắt đầu bất kỳ thứ gì, tài liệu gốc của người dùng (PDF, Word, TXT...) phải được chuyển thành văn bản thuần túy và làm sạch.
+
+### 3.2 Luồng xử lý
+
+```
+[File upload từ User]
+ ↓
+TextProcessor.extract_from_files(file_paths)
+ ↓ (gọi FileParser.extract_from_multiple)
+[raw_text: str] ← văn bản thô chưa sạch
+ ↓
+TextProcessor.preprocess_text(raw_text)
+ ↓ (chuẩn hóa newline, xóa khoảng trắng thừa)
+[document_text: str] ← văn bản sạch, sẵn sàng dùng
+```
+
+### 3.3 Chi tiết xử lý làm sạch
+
+`preprocess_text()` thực hiện 3 bước:
+1. Chuẩn hóa ký tự xuống dòng: `\r\n` (Windows) và `\r` (Mac cũ) → `\n` (Linux chuẩn)
+2. Loại bỏ dòng trống liên tiếp: nếu có 3 dòng trống liên tiếp → rút về tối đa 2
+3. Xóa khoảng trắng đầu/cuối mỗi dòng
+
+### 3.4 Output
+
+`document_text: str` — Chuỗi văn bản sạch, được dùng ở hai nơi sau đó:
+- Truyền vào **Zep Graph Builder** để xây đồ thị (qua pipeline riêng, trước khi vào pipeline Simulation)
+- Truyền thẳng vào **SimulationConfigGenerator** làm ngữ cảnh cho LLM sinh tham số
+
+> **Lưu ý quan trọng:** Pipeline Simulation giả sử Zep Graph đã được build từ trước. Nghĩa là người dùng phải chạy bước "Phân tích tài liệu / Build Graph" trước, rồi mới chạy Simulation.
+
+---
+
+## 4. Giai đoạn 1 — Khởi tạo Simulation
+
+**File:** `backend/app/services/simulation_manager.py`
+**Hàm:** `SimulationManager.create_simulation()`
+
+### 4.1 Mục đích
+
+Tạo một "phòng mô phỏng" rỗng với ID định danh riêng, chưa có dữ liệu gì. Giống như đặt một bàn làm việc trống trước khi bắt đầu.
+
+### 4.2 Input
+
+```python
+project_id: str # ID dự án (gắn với tài liệu gốc)
+graph_id: str # ID của Zep Graph đã được build từ tài liệu
+enable_twitter: bool # Có chạy mô phỏng Twitter không?
+enable_reddit: bool # Có chạy mô phỏng Reddit không?
+```
+
+### 4.3 Luồng xử lý
+
+```python
+# 1. Tạo ID ngẫu nhiên
+simulation_id = f"sim_{uuid4().hex[:12]}"
+# Ví dụ: "sim_a3f9c2b81e4d"
+
+# 2. Khởi tạo object trạng thái
+state = SimulationState(
+ simulation_id=simulation_id,
+ status=SimulationStatus.CREATED,
+ ...
+)
+
+# 3. Lưu ra file
+uploads/simulations/{simulation_id}/state.json
+
+# 4. Cache vào RAM
+self._simulations[simulation_id] = state
+```
+
+### 4.4 Output
+
+`SimulationState` object với `status = CREATED`
+
+**Nội dung `state.json` lúc này:**
+```json
+{
+ "simulation_id": "sim_a3f9c2b81e4d",
+ "project_id": "proj_xyz",
+ "graph_id": "graph_abc",
+ "enable_twitter": true,
+ "enable_reddit": true,
+ "status": "created",
+ "entities_count": 0,
+ "profiles_count": 0,
+ "config_generated": false,
+ "created_at": "2026-05-04T10:00:00"
+}
+```
+
+### 4.5 Cơ chế load lại trạng thái
+
+`SimulationManager` có hai tầng cache:
+1. **RAM cache** (`_simulations` dict): Truy cập tức thì, mất khi restart server
+2. **File cache** (`state.json`): Persistent, load lại khi RAM không có
+
+Khi gọi `_load_simulation_state(simulation_id)`:
+- Tìm trong RAM trước → nếu có, trả về luôn
+- Nếu không có → đọc từ `state.json` → parse → lưu vào RAM → trả về
+
+---
+
+## 5. Giai đoạn 2A — Đọc Entity từ Zep Graph
+
+**File:** `backend/app/services/zep_entity_reader.py`
+**Hàm:** `ZepEntityReader.filter_defined_entities()`
+
+### 5.1 Zep Graph là gì?
+
+Zep là một **Graph Database** (cơ sở dữ liệu dạng đồ thị). Sau khi người dùng upload tài liệu, hệ thống đã phân tích và lưu các thông tin vào Zep dưới dạng:
+- **Node** (Nút): Đại diện cho một thực thể (người, tổ chức, địa điểm, sự kiện...)
+- **Edge** (Cạnh): Đại diện cho mối quan hệ giữa hai thực thể ("A là giám đốc của B", "C phát biểu về D"...)
+
+**Ví dụ về một Graph từ tài liệu về trường đại học:**
+```
+[Trần Văn A] --[là sinh viên của]--> [Đại học X]
+[Nguyễn B] --[là giảng viên của]--> [Đại học X]
+[Đại học X] --[tăng học phí]--> [Năm học 2025]
+[VnExpress] --[đưa tin về]--> [Đại học X]
+```
+
+### 5.2 Cấu trúc dữ liệu EntityNode
+
+```python
+@dataclass
+class EntityNode:
+ uuid: str # ID định danh duy nhất trong Zep
+ name: str # Tên ("Trần Văn A", "Đại học X")
+ labels: List[str] # Loại thực thể ["Entity", "Student"]
+ summary: str # Tóm tắt do Zep tự sinh ra
+ attributes: Dict # Thuộc tính bổ sung {"age": 20, "major": "CNTT"}
+ related_edges: List # Các quan hệ liên quan
+ related_nodes: List # Các node khác có kết nối
+```
+
+**Ví dụ thực tế một EntityNode:**
+```json
+{
+ "uuid": "e1a2b3c4-...",
+ "name": "Trần Văn An",
+ "labels": ["Entity", "Student"],
+ "summary": "Sinh viên năm 3 ngành CNTT tại Đại học X, tích cực tham gia các hoạt động sinh viên",
+ "attributes": {"year": 3, "major": "Computer Science"},
+ "related_edges": [
+ {"direction": "outgoing", "edge_name": "studies_at", "fact": "Trần Văn An học tại Đại học X"}
+ ],
+ "related_nodes": [
+ {"name": "Đại học X", "labels": ["Entity", "University"], "summary": "..."}
+ ]
+}
+```
+
+### 5.3 Luồng xử lý chi tiết
+
+```
+filter_defined_entities(graph_id, defined_entity_types, enrich_with_edges=True)
+ |
+ ├── get_all_nodes(graph_id)
+ │ └── fetch_all_nodes() — có phân trang, retry 3 lần, backoff 2s→4s→8s
+ │ → List[Dict] — toàn bộ node trong Graph
+ │
+ ├── get_all_edges(graph_id)
+ │ └── fetch_all_edges() — có phân trang, retry 3 lần
+ │ → List[Dict] — toàn bộ edge trong Graph
+ │
+ ├── Xây dựng node_map: {uuid → node_data}
+ │
+ └── Lặp qua từng node:
+ ├── Lọc: giữ lại node có custom label (khác "Entity" và "Node")
+ │ Ví dụ: ["Entity", "Student"] → giữ, vì có "Student"
+ │ Ví dụ: ["Entity"] → bỏ, vì chỉ có "Entity"
+ │
+ ├── Nếu defined_entity_types được chỉ định:
+ │ chỉ giữ node có label khớp với danh sách
+ │
+ ├── Tìm related_edges: duyệt all_edges, lấy các edge
+ │ có source_node_uuid HOẶC target_node_uuid = node.uuid
+ │ → gán direction: "outgoing" hoặc "incoming"
+ │
+ └── Tìm related_nodes: từ related_node_uuids → tra node_map
+ → lấy thông tin cơ bản (uuid, name, labels, summary)
+```
+
+### 5.4 Output
+
+```python
+FilteredEntities(
+ entities=[EntityNode, EntityNode, ...], # Danh sách entity đã lọc
+ entity_types={"Student", "University", "MediaOutlet"}, # Các loại xuất hiện
+ total_count=150, # Tổng số node trong graph
+ filtered_count=47, # Số entity hợp lệ sau lọc
+)
+```
+
+### 5.5 Cơ chế Retry
+
+Mọi lời gọi Zep API đều được bọc trong `_call_with_retry()`:
+- Thử tối đa **3 lần**
+- Độ trễ giữa các lần: **2s → 4s → 8s** (Exponential backoff)
+- Nếu vẫn fail sau 3 lần → raise exception lên caller
+
+### 5.6 Fallback nghiêm trọng
+
+Nếu `filtered_count == 0` sau khi lọc:
+```python
+state.status = SimulationStatus.FAILED
+state.error = "No valid entities extracted for simulation. Please check if the Graph was generated properly with valid text."
+```
+Pipeline **dừng hoàn toàn**, không tiếp tục.
+
+---
+
+## 6. Giai đoạn 2B — Sinh Agent Profile
+
+**File:** `backend/app/services/oasis_profile_generator.py`
+**Hàm:** `OasisProfileGenerator.generate_profiles_from_entities()`
+
+### 6.1 Mục đích
+
+Mỗi EntityNode (thực thể trong Zep) cần được "hóa thân" thành một Agent AI với đủ nhân cách, lịch sử, cách nói chuyện... để khi chạy mô phỏng, Agent đó behave đúng như người thật. Đây là bước **chuyển đổi từ dữ liệu khô sang nhân vật sống động**.
+
+**Ví dụ:**
+- EntityNode: `{name: "Trần Văn An", labels: ["Student"], summary: "Sinh viên năm 3 CNTT"}`
+- OasisAgentProfile: `{bio: "Sinh viên CNTT năm 3 tại Đại học X, mê game và lo lắng về học phí tăng", persona: "Trần Văn An là sinh viên 21 tuổi, MBTI INFP, hay đăng status buổi đêm, viết theo kiểu Gen Z, thường phản ứng nhanh với tin tức về học phí..."}`
+
+### 6.2 Cấu trúc OasisAgentProfile
+
+```python
+@dataclass
+class OasisAgentProfile:
+ user_id: int # Index (0, 1, 2, ...)
+ user_name: str # "tran_van_an_492" (tên tài khoản, random suffix)
+ name: str # "Trần Văn An"
+ bio: str # Tiểu sử ngắn hiển thị công khai (~200 ký tự)
+ persona: str # Mô tả nhân cách chi tiết (~2000 từ) — LLM dùng làm System Prompt
+ karma: int # (Reddit) Điểm karma
+ friend_count: int # (Twitter) Số bạn bè
+ follower_count: int # (Twitter) Số người theo dõi
+ age: int
+ gender: str # "male" / "female" / "other"
+ mbti: str # "INFP", "ESTJ", ...
+ country: str # "Việt Nam"
+ profession: str # "Sinh viên"
+ interested_topics: List[str]
+```
+
+**Điểm quan trọng về `persona`:** Đây là chuỗi văn bản ~2000 từ mà OASIS sẽ nhét vào **System Prompt** của Agent khi nó "suy nghĩ" và đưa ra hành động. Nó quyết định toàn bộ cách Agent đó hoạt động trong mô phỏng.
+
+### 6.3 Luồng song song (ThreadPoolExecutor)
+
+```python
+with ThreadPoolExecutor(max_workers=parallel_count) as executor:
+ # Giao việc cho tất cả entity cùng lúc
+ futures = {executor.submit(generate_single_profile, idx, entity): (idx, entity)
+ for idx, entity in enumerate(entities)}
+
+ # Thu gom kết quả khi từng profile hoàn thành
+ for future in as_completed(futures):
+ result_idx, profile, error = future.result()
+ profiles[result_idx] = profile
+ save_profiles_realtime() # Lưu ngay, không chờ tất cả xong
+```
+
+Mặc định `parallel_count = 3` — tức là tại bất kỳ thời điểm nào, tối đa 3 entity được xử lý đồng thời.
+
+### 6.4 Luồng xử lý cho từng entity
+
+```
+generate_single_profile(idx, entity)
+ |
+ ├── _build_entity_context(entity)
+ │ ├── 1. Lấy attributes của node
+ │ ├── 2. Lấy facts từ related_edges (đã có từ bước trước)
+ │ ├── 3. Lấy summary của related_nodes
+ │ └── 4. _search_zep_for_entity() — Gọi Zep Vector Search
+ │ ├── search_edges(query) — thread 1
+ │ └── search_nodes(query) — thread 2
+ │ (2 thread chạy song song, retry 3 lần mỗi cái)
+ │ → ghép thành context string
+ │
+ ├── Phân loại entity type:
+ │ ├── INDIVIDUAL: student, alumni, professor, person, publicfigure...
+ │ └── GROUP: university, governmentagency, ngo, mediaoutlet...
+ │
+ └── _generate_profile_with_llm(entity_name, type, summary, attrs, context)
+ ├── Chọn prompt template:
+ │ ├── Individual → _build_individual_persona_prompt()
+ │ └── Group → _build_group_persona_prompt()
+ │
+ └── Gọi LLM (retry vòng lặp):
+ Attempt 1: temperature=0.7
+ Attempt 2: temperature=0.6 (nếu fail)
+ Attempt 3: temperature=0.5 (nếu fail)
+ → JSON với bio, persona, age, gender, mbti, country...
+```
+
+### 6.5 Chi tiết Zep Vector Search
+
+`_search_zep_for_entity()` dùng **semantic search** — tìm kiếm theo nghĩa, không phải từ khóa:
+
+```python
+comprehensive_query = f"Provide all facts, activities, relationships, and context about: {entity_name}"
+
+# Chạy song song 2 loại search
+search_edges() → tìm trong các mối quan hệ (edge) — lấy tối đa 30 kết quả
+search_nodes() → tìm trong các node thực thể — lấy tối đa 20 kết quả
+```
+
+Mỗi search có retry riêng: 3 lần, backoff 2s→4s→8s.
+Nếu timeout tổng (30s) hoặc fail hoàn toàn → trả về `{"facts": [], "node_summaries": [], "context": ""}` và tiếp tục không lỗi.
+
+### 6.6 Xử lý JSON từ LLM
+
+LLM đôi khi trả về JSON bị hỏng (cắt ngang do token limit, ký tự lạ...). Hệ thống có chuỗi sửa lỗi:
+
+```
+LLM response
+ |
+ ├── finish_reason == "length"? → _fix_truncated_json()
+ │ Đếm { và } chưa đóng → tự thêm vào cuối
+ │
+ ├── json.loads() lỗi? → _try_fix_json()
+ │ ├── Gọi _fix_truncated_json() trước
+ │ ├── Dùng regex tìm khối {...} lớn nhất
+ │ ├── Clean newline trong string values
+ │ ├── Xóa control characters (0x00–0x1F)
+ │ └── Nếu vẫn fail → regex mót bio và persona riêng lẻ
+ │
+ └── Sau 3 attempt đều fail → _generate_profile_rule_based()
+```
+
+### 6.7 Fallback theo Rule (khi LLM hoàn toàn thất bại)
+
+| Entity Type | age | gender | activity | influence |
+|---|---|---|---|---|
+| student / alumni | 18–30 | random | Cao, chủ yếu buổi tối | Thấp |
+| publicfigure / expert / faculty | 35–60 | random | Trung bình | Cao |
+| mediaoutlet | 30 (ảo) | "other" | Cả ngày | Rất cao |
+| university / governmentagency / ngo | 30 (ảo) | "other" | Giờ hành chính | Rất cao |
+| Default | 25–50 | random | Trung bình | Trung bình |
+
+### 6.8 Lưu file kết quả
+
+**Reddit format** (`reddit_profiles.json`):
+```json
+[
+ {
+ "user_id": 0,
+ "username": "tran_van_an_492",
+ "name": "Trần Văn An",
+ "bio": "Sinh viên CNTT năm 3, lo lắng về học phí...",
+ "persona": "Trần Văn An là sinh viên 21 tuổi...(2000 từ mô tả nhân cách)...",
+ "karma": 1500,
+ "age": 21,
+ "gender": "male",
+ "mbti": "INFP",
+ "country": "Việt Nam"
+ }
+]
+```
+
+**Twitter format** (`twitter_profiles.csv`):
+```csv
+user_id,name,username,user_char,description
+0,Trần Văn An,tran_van_an_492,"bio + persona ghép lại (System Prompt cho LLM)","bio ngắn"
+```
+
+**Realtime save:** Sau mỗi profile hoàn thành (không cần chờ tất cả), file được ghi ngay. Điều này đảm bảo nếu server crash giữa chừng, dữ liệu đã gen không bị mất.
+
+---
+
+## 7. Giai đoạn 2C — Sinh Simulation Config
+
+**File:** `backend/app/services/simulation_config_generator.py`
+**Hàm:** `SimulationConfigGenerator.generate_config()`
+
+### 7.1 Mục đích
+
+Đây là giai đoạn LLM "thiết kế kịch bản mô phỏng". Dựa trên yêu cầu của người dùng + dữ liệu entity, LLM quyết định:
+- Mô phỏng kéo dài bao nhiêu giờ?
+- Giờ nào sẽ có nhiều hoạt động?
+- Sự kiện ban đầu nào sẽ khởi động cuộc thảo luận?
+- Mỗi Agent sẽ hoạt động theo kiểu gì?
+
+### 7.2 Luồng tổng quát (tuần tự, không song song)
+
+```
+generate_config(simulation_id, project_id, graph_id, simulation_requirement,
+ document_text, entities, enable_twitter, enable_reddit)
+ │
+ ├── _build_context() → context string (tối đa 50,000 ký tự)
+ │ ├── simulation_requirement (giữ nguyên)
+ │ ├── entity summary (tóm tắt theo loại, tối đa 20 entity/loại)
+ │ └── document_text (phần còn lại, bị cắt nếu quá dài)
+ │
+ ├── Step 1: _generate_time_config() → TimeSimulationConfig
+ ├── Step 2: _generate_event_config() → EventConfig (có initial posts)
+ ├── Step 3..N: _generate_agent_configs_batch() (15 agent/lần)
+ ├── _assign_initial_post_agents() → gán agent_id cho từng post
+ └── Hardcode: Twitter + Reddit PlatformConfig
+```
+
+### 7.3 Step 1 — Cấu hình Thời gian
+
+**Hàm:** `_generate_time_config(context, num_entities)`
+
+LLM nhận context đã được cắt còn 10,000 ký tự và sinh ra:
+
+```json
+{
+ "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, 11, 12, 13, 14, 15, 16, 17, 18],
+ "reasoning": "Chủ đề học phí thu hút cả sinh viên và phụ huynh, cao điểm tối..."
+}
+```
+
+**Validation sau khi nhận:**
+```python
+# Đảm bảo agents_per_hour không vượt tổng số agent thực tế
+if agents_per_hour_min > num_entities:
+ agents_per_hour_min = max(1, num_entities // 10)
+if agents_per_hour_max > num_entities:
+ 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)
+```
+
+**Fallback (LLM fail):**
+```python
+{
+ "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],
+ ...
+}
+```
+
+**Ý nghĩa các thông số:**
+- `total_simulation_hours = 72` → Mô phỏng 3 ngày thực (72 vòng nếu mỗi vòng = 1 giờ)
+- `minutes_per_round = 60` → Mỗi vòng đại diện cho 1 giờ trong thế giới thực
+- `agents_per_hour_min/max = 5/30` → Mỗi giờ, OASIS sẽ kích hoạt ngẫu nhiên 5 đến 30 agent để hoạt động
+
+### 7.4 Step 2 — Cấu hình Sự kiện
+
+**Hàm:** `_generate_event_config(context, simulation_requirement, entities)`
+
+LLM sinh ra "ngòi nổ" cho cuộc mô phỏng — các bài đăng đầu tiên sẽ khởi động thảo luận:
+
+```json
+{
+ "hot_topics": ["học phí", "biểu tình", "phản đối", "giáo dục công lập"],
+ "narrative_direction": "Thông báo tăng học phí gây ra làn sóng phản ứng mạnh từ sinh viên, dần leo thang thành phong trào...",
+ "initial_posts": [
+ {
+ "content": "CHÍNH THỨC: Đại học X thông báo tăng học phí 20% từ học kỳ tới. Xem chi tiết tại...",
+ "poster_type": "MediaOutlet"
+ },
+ {
+ "content": "Không thể chấp nhận được! Học phí đã cao mà còn tăng thêm?! #HọcPhíTăng",
+ "poster_type": "Student"
+ }
+ ],
+ "reasoning": "..."
+}
+```
+
+**Sau đó:** `_assign_initial_post_agents()` ghép mỗi post với agent phù hợp:
+
+```
+poster_type = "MediaOutlet"
+ │
+ ├── 1. Direct match: agents_by_type["mediaoutlet"] → lấy agent đầu tiên
+ │
+ ├── 2. Alias match (nếu direct fail):
+ │ type_aliases = {
+ │ "mediaoutlet": ["mediaoutlet", "media"],
+ │ "official": ["official", "university", "governmentagency", "government"],
+ │ "student": ["student", "person"],
+ │ ...
+ │ }
+ │
+ └── 3. Fallback: agent có influence_weight cao nhất
+```
+
+Kết quả: `poster_agent_id` được gán vào mỗi post:
+```json
+{"content": "CHÍNH THỨC...", "poster_type": "MediaOutlet", "poster_agent_id": 12}
+```
+
+### 7.5 Step 3..N — Cấu hình Agent (chia batch)
+
+**Hàm:** `_generate_agent_configs_batch(context, entities_batch, start_idx, simulation_requirement)`
+
+Do số lượng agent có thể lớn, không thể đưa tất cả vào một prompt. Hệ thống chia nhỏ **15 agent một lần**:
+
+```
+47 agents → batch 1 (agent 0-14) + batch 2 (agent 15-29) + batch 3 (agent 30-44) + batch 4 (agent 45-46)
+```
+
+Với mỗi batch, gửi cho LLM danh sách entity và nhận lại cấu hình hoạt động:
+
+```json
+{
+ "agent_configs": [
+ {
+ "agent_id": 0,
+ "activity_level": 0.8,
+ "posts_per_hour": 0.6,
+ "comments_per_hour": 1.5,
+ "active_hours": [8, 9, 10, 18, 19, 20, 21, 22, 23],
+ "response_delay_min": 1,
+ "response_delay_max": 15,
+ "sentiment_bias": -0.3,
+ "stance": "opposing",
+ "influence_weight": 0.8
+ }
+ ]
+}
+```
+
+**Giải thích các thông số:**
+- `activity_level`: 0.0–1.0, xác suất agent "thức dậy" trong một round
+- `posts_per_hour`: Trung bình số bài đăng mới mỗi giờ
+- `comments_per_hour`: Trung bình số bình luận mỗi giờ
+- `active_hours`: Danh sách giờ trong ngày agent có thể hoạt động (0–23)
+- `response_delay_min/max`: Thời gian (phút mô phỏng) agent chờ trước khi phản ứng với tin hot
+- `sentiment_bias`: -1.0 (rất tiêu cực) → 0.0 (trung lập) → 1.0 (rất tích cực)
+- `stance`: "supportive" / "opposing" / "neutral" / "observer"
+- `influence_weight`: Mức độ bài đăng của agent này được agent khác nhìn thấy và quan tâm
+
+**Fallback theo rule khi LLM fail:**
+
+| Entity Type | activity | active_hours | response_delay | influence |
+|---|---|---|---|---|
+| university / governmentagency | 0.2 | 9–17 | 60–240 phút | 3.0 |
+| mediaoutlet | 0.5 | 7–23 | 5–30 phút | 2.5 |
+| professor / expert / official | 0.4 | 8–21 | 15–90 phút | 2.0 |
+| student | 0.8 | Sáng + Tối | 1–15 phút | 0.8 |
+| alumni | 0.6 | Trưa + Tối | 5–30 phút | 1.0 |
+| Default | 0.7 | 9–23 | 2–20 phút | 1.0 |
+
+### 7.6 Cơ chế retry chung cho mọi LLM call
+
+Tất cả LLM call trong `SimulationConfigGenerator` đi qua `_call_llm_with_retry()`:
+
+```
+Attempt 1: temperature=0.7
+ ↓ (nếu fail hoặc JSON lỗi)
+Attempt 2: temperature=0.6
+ ↓
+Attempt 3: temperature=0.5
+ ↓ (nếu vẫn fail)
+→ raise exception → caller dùng hardcode fallback
+```
+
+Giữa các lần retry nếu lỗi connection: sleep `2*(attempt+1)` giây (2s, 4s).
+
+### 7.7 File đầu ra
+
+`uploads/simulations/{sim_id}/simulation_config.json` — **File quan trọng nhất**, được đọc bởi cả Flask và OASIS scripts:
+
+```json
+{
+ "simulation_id": "sim_a3f9c2b81e4d",
+ "project_id": "proj_xyz",
+ "graph_id": "graph_abc",
+ "simulation_requirement": "Mô phỏng phản ứng của cộng đồng khi Đại học X tăng học phí 20%",
+ "time_config": {
+ "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],
+ "peak_activity_multiplier": 1.5,
+ "off_peak_activity_multiplier": 0.05
+ },
+ "agent_configs": [
+ {
+ "agent_id": 0,
+ "entity_uuid": "e1a2b3...",
+ "entity_name": "Trần Văn An",
+ "entity_type": "Student",
+ "activity_level": 0.8,
+ "stance": "opposing",
+ "influence_weight": 0.8,
+ ...
+ }
+ ],
+ "event_config": {
+ "hot_topics": ["học phí", "biểu tình"],
+ "narrative_direction": "...",
+ "initial_posts": [
+ {
+ "content": "CHÍNH THỨC: Đại học X tăng học phí...",
+ "poster_type": "MediaOutlet",
+ "poster_agent_id": 12
+ }
+ ]
+ },
+ "twitter_config": {
+ "platform": "twitter",
+ "recency_weight": 0.4,
+ "popularity_weight": 0.3,
+ "viral_threshold": 10,
+ "echo_chamber_strength": 0.5
+ },
+ "reddit_config": {
+ "platform": "reddit",
+ "recency_weight": 0.3,
+ "popularity_weight": 0.4,
+ "viral_threshold": 15,
+ "echo_chamber_strength": 0.6
+ }
+}
+```
+
+Sau khi lưu file này, `state.status` được cập nhật thành `READY`.
+
+---
+
+## 8. Giai đoạn 3 — Chạy Simulation (OASIS)
+
+**Files:** `backend/app/services/simulation_runner.py` + `backend/scripts/run_parallel_simulation.py`
+
+### 8.1 Mô hình chạy
+
+Flask **không** chạy OASIS trực tiếp trong cùng process. Thay vào đó:
+
+```
+Flask Process
+ │
+ └── subprocess.Popen(run_parallel_simulation.py --config ...)
+ │
+ ├── OASIS Twitter Environment (async task)
+ └── OASIS Reddit Environment (async task)
+```
+
+**Lý do tách process:** OASIS là framework async nặng, chạy nhiều agent đồng thời. Nếu chạy trong Flask sẽ block hoàn toàn API server. Tách process giúp Flask vẫn nhận được request trong khi OASIS chạy ngầm.
+
+### 8.2 Khởi động Simulation
+
+**Hàm:** `SimulationRunner.start_simulation()`
+
+```python
+# 1. Load config
+with open(config_path) as f:
+ config = json.load(f)
+
+# 2. Tính tổng số round
+total_rounds = int(total_hours * 60 / minutes_per_round)
+# Ví dụ: 72 giờ × 60 phút / 60 phút/round = 72 rounds
+
+# 3. Chọn script
+script_name = "run_parallel_simulation.py" # hoặc twitter/reddit only
+
+# 4. Khởi động subprocess
+process = subprocess.Popen(
+ [sys.executable, script_path, "--config", config_path],
+ cwd=sim_dir,
+ stdout=main_log_file, # stdout → simulation.log
+ stderr=subprocess.STDOUT,
+ start_new_session=True, # Tạo process group mới (quan trọng cho kill)
+ env={...PYTHONUTF8=1...}
+)
+
+# 5. Lưu PID để có thể kill sau này
+state.process_pid = process.pid
+
+# 6. Khởi động thread giám sát
+monitor_thread = Thread(target=cls._monitor_simulation, args=(simulation_id,), daemon=True)
+monitor_thread.start()
+```
+
+### 8.3 Bên trong OASIS Script
+
+**File:** `backend/scripts/run_parallel_simulation.py`
+
+Script này chạy bằng Python asyncio, khởi tạo hai môi trường mạng xã hội:
+
+```python
+# Tạo LLM model (dùng camel-ai ModelFactory)
+model = ModelFactory.create(ModelPlatformType.OPENAI, ...)
+
+# Tạo Twitter environment
+twitter_env = TwitterSocialEnvironment(...)
+twitter_agent_graph = generate_twitter_agent_graph(
+ profiles=twitter_profiles, # Đọc từ twitter_profiles.csv
+ model=model,
+ initial_posts=initial_posts # Đọc từ simulation_config.json
+)
+
+# Tạo Reddit environment
+reddit_env = RedditSocialEnvironment(...)
+reddit_agent_graph = generate_reddit_agent_graph(
+ profiles=reddit_profiles, # Đọc từ reddit_profiles.json
+ model=model,
+ initial_posts=initial_posts
+)
+```
+
+**Vòng lặp Simulation:**
+```
+Round 1 (giờ 0:00 - 1:00)
+ │
+ ├── Tính activity_multiplier:
+ │ hour=0 → off_peak → multiplier=0.05
+ │ hour=20 → peak → multiplier=1.5
+ │
+ ├── Tính số agent active = random(min, max) * multiplier
+ │
+ ├── Chọn ngẫu nhiên N agents từ danh sách
+ │
+ ├── Với mỗi agent được chọn:
+ │ ├── Agent "nhìn" timeline (các bài đăng gần đây)
+ │ ├── LLM suy nghĩ dựa trên persona + nội dung thấy được
+ │ └── LLM chọn một action:
+ │ Twitter: CREATE_POST / LIKE_POST / REPOST / FOLLOW / DO_NOTHING / QUOTE_POST
+ │ Reddit: CREATE_POST / CREATE_COMMENT / LIKE_POST / DISLIKE_POST / SEARCH_POSTS / ...
+ │
+ └── Ghi log ra actions.jsonl
+ {"round": 1, "agent_id": 5, "agent_name": "...", "action_type": "CREATE_POST", "action_args": {...}}
+
+Round 2...
+...
+Round 72 → ghi {"event_type": "simulation_end", "total_rounds": 72, "total_actions": 3420}
+```
+
+### 8.4 Cấu trúc file log
+
+```
+uploads/simulations/{sim_id}/
+├── twitter/
+│ └── actions.jsonl ← mỗi dòng là 1 hành động trên Twitter
+├── reddit/
+│ └── actions.jsonl ← mỗi dòng là 1 hành động trên Reddit
+├── simulation.log ← stdout/stderr của subprocess
+└── run_state.json ← trạng thái real-time (Flask cập nhật)
+```
+
+**Ví dụ một dòng trong `actions.jsonl`:**
+```json
+{
+ "round": 15,
+ "timestamp": "2026-05-04T15:30:00",
+ "agent_id": 3,
+ "agent_name": "tran_van_an_492",
+ "action_type": "CREATE_POST",
+ "action_args": {
+ "content": "Không chịu nổi rồi! Học phí tăng 20% mà lương bố mẹ mình có tăng đâu... #HọcPhíTăng #ĐạiHọcX"
+ },
+ "result": "Post created with id 142",
+ "success": true
+}
+```
+
+**Sự kiện hệ thống (không phải hành động agent):**
+```json
+{"event_type": "round_end", "round": 15, "simulated_hours": 15}
+{"event_type": "simulation_end", "total_rounds": 72, "total_actions": 3420}
+```
+
+### 8.5 Monitor Thread
+
+**Hàm:** `SimulationRunner._monitor_simulation()` — chạy liên tục trong nền:
+
+```
+Mỗi 2 giây:
+ ├── Đọc twitter/actions.jsonl từ vị trí đã đọc lần trước (file seek)
+ │ → parse từng dòng JSON mới
+ │ → cập nhật state.twitter_current_round, state.twitter_actions_count
+ │ → phát hiện event_type "simulation_end" → state.twitter_completed = True
+ │
+ ├── Làm tương tự với reddit/actions.jsonl
+ │
+ ├── Kiểm tra _check_all_platforms_completed():
+ │ Nếu cả 2 platform đều completed → state.runner_status = COMPLETED
+ │
+ └── Lưu run_state.json (để Frontend có thể poll tiến độ qua API)
+```
+
+**Khi subprocess kết thúc:**
+- `exit_code = 0` → `COMPLETED`
+- `exit_code != 0` → `FAILED`, đọc 2000 ký tự cuối `simulation.log` làm error message
+- Dù thế nào: đóng file handles, xóa khỏi `_processes` dict
+
+### 8.6 Dừng Simulation
+
+**Hàm:** `SimulationRunner.stop_simulation()`
+
+Phải dừng cả **process group** (bao gồm subprocess và các child của nó):
+
+```python
+# Unix/Linux/Mac:
+pgid = os.getpgid(process.pid)
+os.killpg(pgid, signal.SIGTERM) # Gửi SIGTERM nhẹ nhàng
+process.wait(timeout=10) # Chờ tối đa 10s
+# Nếu timeout → os.killpg(pgid, signal.SIGKILL) # Kill cưỡng bức
+
+# Windows:
+subprocess.run(['taskkill', '/PID', str(pid), '/T']) # /T = kill cả tree
+# Nếu timeout → subprocess.run(['taskkill', '/F', '/PID', str(pid), '/T']) # /F = force
+```
+
+Lý do phải dùng process group thay vì kill process đơn: OASIS script có thể spawn thêm child process, phải kill toàn bộ.
+
+### 8.7 IPC — Interview Agent đang chạy
+
+Trong khi simulation đang chạy, người dùng có thể "phỏng vấn" một agent bất kỳ:
+
+```
+Flask API nhận request interview(agent_id=5, prompt="Bạn nghĩ gì về việc tăng học phí?")
+ │
+ └── SimulationIPCClient.send_interview()
+ │
+ ├── Ghi file: ipc_commands/{uuid}.json
+ │ {"command_id": "...", "command_type": "interview", "args": {"agent_id": 5, "prompt": "..."}}
+ │
+ └── Poll ipc_responses/{uuid}.json (mỗi 0.5s, timeout 60s)
+ │
+ └── (Bên trong OASIS script, ParallelIPCHandler poll ipc_commands/*)
+ │
+ ├── Phát hiện command mới
+ ├── Chạy ManualAction INTERVIEW với agent 5
+ ├── Agent 5 LLM trả lời câu hỏi dựa trên persona
+ └── Ghi kết quả: ipc_responses/{uuid}.json
+ {"command_id": "...", "status": "completed", "result": {"response": "..."}}
+```
+
+**Fallback interview:** Nếu environment không alive (env_status.json không có status="alive") → raise `ValueError` ngay, không gửi IPC.
+
+---
+
+## 9. Giai đoạn 4 — Tạo Report (ReACT Agent)
+
+**File:** `backend/app/services/report_agent.py`
+
+### 9.1 ReACT là gì?
+
+ReACT (Reason + Act) là một kỹ thuật prompting cho LLM, trong đó LLM được phép:
+1. **Suy nghĩ** (Reason) về việc cần làm
+2. **Gọi công cụ** (Act) để lấy thêm thông tin
+3. **Quan sát** kết quả trả về
+4. Lặp lại cho đến khi có đủ thông tin để trả lời
+
+Thay vì "hỏi LLM một câu, nhận một câu trả lời", ReACT cho phép LLM tự chủ đi tìm dữ liệu trước khi viết.
+
+### 9.2 Triết lý của báo cáo
+
+Báo cáo được định vị là **"Báo cáo Dự báo Tương lai"** — không phải phân tích dữ liệu khô khan, mà là: *"Trong thế giới mô phỏng, điều này đã xảy ra — đây là dự báo về những gì có thể xảy ra trong tương lai thực."*
+
+Các Agent trong mô phỏng được xem là **đại diện cho hành vi con người trong tương lai**.
+
+### 9.3 Luồng tổng quát
+
+```
+generate_report(simulation_id, graph_id, simulation_requirement)
+ │
+ ├── Phase A: Planning (Lên dàn ý)
+ │ ├── Query Zep → lấy statistics (total nodes, edges, entity types)
+ │ ├── Lấy sample facts từ graph
+ │ ├── LLM (PLAN_SYSTEM_PROMPT + context) → JSON outline
+ │ └── → ReportOutline {title, summary, sections: [2-5 sections]}
+ │
+ ├── Phase B: Generating (Viết từng section)
+ │ └── Với mỗi section:
+ │ └── ReACT loop (tối đa 5 lần tool call, tối thiểu 3):
+ │ ├── LLM suy nghĩ → gọi tool → nhận kết quả → suy nghĩ tiếp
+ │ └── Khi đủ data → "Final Answer: [nội dung section]"
+ │
+ └── Phase C: Assembly (Ghép báo cáo)
+ ├── Ghép tất cả sections thành Markdown
+ └── Lưu report.json + report.md
+```
+
+### 9.4 Phase A — Lên dàn ý
+
+**Input vào LLM:**
+```
+PLAN_SYSTEM_PROMPT:
+ "Bạn là chuyên gia viết Báo cáo Dự báo Tương lai, với góc nhìn
+ của Chúa về thế giới mô phỏng..."
+
+PLAN_USER_PROMPT:
+ "Biến số tiêm vào: {simulation_requirement}
+ Quy mô: {total_nodes} nodes, {total_edges} edges
+ Phân phối loại entity: {entity_types}
+ Sample facts: {related_facts_json}"
+```
+
+**Output:**
+```json
+{
+ "title": "Dự báo Làn sóng Phản đối Học phí Tăng tại Đại học X",
+ "summary": "Mô phỏng cho thấy quyết định tăng 20% học phí sẽ kích hoạt làn sóng biểu tình trực tuyến trong vòng 48 giờ, dẫn đầu bởi sinh viên năm 3-4",
+ "sections": [
+ {"title": "Sự bùng nổ ban đầu trên mạng xã hội", "description": "Phân tích..."},
+ {"title": "Phân cực dư luận: Ủng hộ vs Phản đối", "description": "..."},
+ {"title": "Phản ứng của các bên liên quan", "description": "..."},
+ {"title": "Dự báo xu hướng và rủi ro", "description": "..."}
+ ]
+}
+```
+
+### 9.5 Phase B — Viết từng Section (ReACT)
+
+Với mỗi section, một vòng hội thoại mới bắt đầu:
+
+**System Prompt** cho LLM bao gồm:
+- Tiêu đề và tóm tắt báo cáo
+- Yêu cầu mô phỏng
+- Tiêu đề section đang viết
+- Mô tả 4 công cụ có sẵn
+- Luật: phải gọi tool 3-5 lần, không dùng heading (#), dịch mọi content sang tiếng Việt
+
+**4 công cụ khả dụng:**
+
+| Tool | Mô tả | Dùng khi |
+|---|---|---|
+| `insight_forge(query)` | Tự chia query thành sub-questions, search Zep đa chiều (facts + entities + relationships) | Phân tích sâu một chủ đề phức tạp |
+| `panorama_search(query)` | Lấy toàn cảnh evolution của event, phân biệt facts hiện tại vs lịch sử | Hiểu timeline, diễn biến sự kiện |
+| `quick_search(query)` | Search đơn giản, nhanh | Xác minh một thông tin cụ thể |
+| `interview_agents(topic, agent_types, question)` | Phỏng vấn thực Agent đang chạy qua IPC | Cần góc nhìn first-person từ nhân vật |
+
+**Ví dụ vòng ReACT:**
+
+```
+LLM lần 1:
+ [Suy nghĩ] Tôi cần hiểu sinh viên đã phản ứng như thế nào...
+
+ {"name": "insight_forge", "parameters": {"query": "phản ứng của sinh viên khi học phí tăng"}}
+
+
+[Hệ thống inject kết quả tool]
+ Observation: "Facts: Trần Văn An đăng status phản đối lúc 20:15.
+ Nhóm sinh viên tự phát tạo hashtag #HọcPhíTăng lúc 21:00..."
+
+LLM lần 2:
+ [Suy nghĩ] Tốt, cần thêm góc nhìn từ truyền thông...
+
+ {"name": "interview_agents", "parameters": {
+ "topic": "học phí tăng",
+ "agent_types": ["MediaOutlet"],
+ "question": "Bạn đưa tin về vụ tăng học phí này như thế nào?"
+ }}
+
+
+[Kết quả interview thực từ OASIS qua IPC]
+ Observation: "VnExpress_reporter_291: Chúng tôi đã đăng bài ngay khi nhận được
+ thông cáo chính thức, thu hút 50,000 lượt xem trong 2 giờ đầu..."
+
+LLM lần 3:
+ {"name": "panorama_search", ...}
+
+LLM lần 4:
+ {"name": "quick_search", ...}
+
+LLM lần 5 (khi đã đủ data):
+ Final Answer:
+ Trong 24 giờ đầu sau thông báo, mạng xã hội đã chứng kiến một làn sóng phản ứng
+ chưa từng có...
+
+ **Giai đoạn bùng nổ (Giờ 0-4)**
+
+ Ngay khi thông báo được đăng tải, các tài khoản truyền thông lớn nhanh chóng
+ tiếp nhận và khuếch đại:
+
+ > "Trần Văn An sẽ nói: Không chịu nổi rồi! Học phí tăng 20% mà lương bố mẹ
+ mình có tăng đâu... #HọcPhíTăng"
+
+ Phản ứng này phản ánh tâm lý chung của nhóm sinh viên có hoàn cảnh khó khăn...
+```
+
+**Quy tắc định dạng nghiêm ngặt:**
+- ❌ Không dùng `#`, `##`, `###` (heading)
+- ✅ Dùng `**bold**` thay thế
+- ✅ Quote agent speech bằng `>` format
+- ✅ Dịch mọi content tiếng Anh/tiếng Trung sang tiếng Việt
+- ❌ Không fabricate thông tin không có trong simulation data
+
+### 9.6 Logging chi tiết
+
+Mọi bước trong quá trình tạo report được ghi vào `agent_log.jsonl`:
+
+```
+uploads/reports/{report_id}/
+├── agent_log.jsonl ← JSONL, mỗi dòng là một event (tool_call, llm_response, section_complete...)
+├── console_log.txt ← Log dạng console text (INFO/WARNING level)
+├── report.json ← Full report object
+└── report.md ← Nội dung Markdown
+```
+
+**Ví dụ nội dung `agent_log.jsonl`:**
+```json
+{"timestamp": "2026-05-04T16:00:00", "action": "planning_complete", "stage": "planning", "details": {"outline": {...}}}
+{"timestamp": "2026-05-04T16:01:00", "action": "section_start", "stage": "generating", "section_title": "Sự bùng nổ ban đầu"}
+{"timestamp": "2026-05-04T16:01:05", "action": "tool_call", "details": {"tool_name": "insight_forge", "parameters": {...}}}
+{"timestamp": "2026-05-04T16:01:10", "action": "tool_result", "details": {"tool_name": "insight_forge", "result": "...(full)"}}
+{"timestamp": "2026-05-04T16:02:00", "action": "section_complete", "section_title": "Sự bùng nổ ban đầu", "details": {"content": "...(full)"}}
+{"timestamp": "2026-05-04T16:05:00", "action": "report_complete", "details": {"total_sections": 4, "total_time_seconds": 300.5}}
+```
+
+Frontend lắng nghe log này để hiển thị tiến độ real-time và nội dung từng section khi hoàn thành.
+
+---
+
+## 10. Cơ chế Fallback toàn hệ thống
+
+### 10.1 Bảng tổng hợp
+
+| Giai đoạn | Thành phần | Lỗi | Fallback | Severity |
+|---|---|---|---|---|
+| 2A | ZepEntityReader | API lỗi | Retry 3 lần, backoff 2s→4s→8s | Tiếp tục |
+| 2A | ZepEntityReader | 0 entity sau lọc | **Dừng pipeline**, status=FAILED | **Nghiêm trọng** |
+| 2B | Zep Vector Search | Timeout / lỗi | Bỏ qua, context rỗng, tiếp tục | Nhẹ |
+| 2B | LLM Profile Gen | JSON lỗi | `_fix_truncated_json()` → `_try_fix_json()` | Tự phục hồi |
+| 2B | LLM Profile Gen | 3 lần fail | `_generate_profile_rule_based()` | Degraded |
+| 2B | ThreadPoolExecutor | Exception 1 entity | Dùng fallback profile, tiếp tục | Nhẹ |
+| 2C | LLM Time Config | Fail | Hardcoded defaults (72h, 60min/round) | Degraded |
+| 2C | LLM Event Config | Fail | Empty hot_topics, no initial_posts | Degraded |
+| 2C | LLM Agent Config | 1 agent thiếu | `_generate_agent_config_by_rule()` | Nhẹ |
+| 2C | poster_type match | Không khớp | Agent có influence_weight cao nhất | Nhẹ |
+| 2C | LLM JSON | Cắt ngang | `_fix_truncated_json()` | Tự phục hồi |
+| 3 | subprocess | exit_code != 0 | RunnerStatus=FAILED, đọc log | Nghiêm trọng |
+| 3 | SIGTERM | Timeout 10s | SIGKILL (Unix) / taskkill /F (Win) | Forced |
+| 3 | IPC Interview | Environment chết | ValueError ngay, không wait | Nhẹ |
+| 3 | IPC Interview | Timeout 60s | TimeoutError trả về caller | Nhẹ |
+| 4 | Zep tool call | Lỗi | Log warning, tiếp tục ReACT | Nhẹ |
+| 4 | Interview tool | Env không alive | Trả về error message, tiếp tục | Nhẹ |
+
+### 10.2 Mức độ ảnh hưởng
+
+**Scenario 1 — LLM hoàn toàn không khả dụng:**
+- Profile: Tất cả dùng rule-based → Profiles cơ bản nhưng vẫn chạy được
+- Config: Hardcoded defaults → Mô phỏng với cài đặt chuẩn thay vì tùy chỉnh
+- Report: Không thể tạo (phụ thuộc LLM hoàn toàn)
+
+**Scenario 2 — Zep không khả dụng:**
+- Nếu không fetch được entity → Pipeline dừng ở 2A
+- Nếu Vector Search fail → Profile thiếu context nhưng vẫn tạo được
+
+**Scenario 3 — OASIS subprocess crash:**
+- RunnerStatus = FAILED
+- Log lỗi được lưu vào simulation.log
+- Người dùng có thể cleanup rồi chạy lại
+
+---
+
+## 11. Cấu trúc file và thư mục
+
+### 11.1 Thư mục Simulation
+
+```
+uploads/simulations/{simulation_id}/
+│
+├── state.json ← Trạng thái lifecycle (CREATED/PREPARING/READY/RUNNING/...)
+├── run_state.json ← Trạng thái runtime (round hiện tại, actions count, ...)
+├── simulation_config.json ← Config đầy đủ (time, agents, events, platforms)
+│
+├── reddit_profiles.json ← Profile agents format Reddit
+├── twitter_profiles.csv ← Profile agents format Twitter
+│
+├── simulation.log ← stdout + stderr của OASIS subprocess
+├── env_status.json ← Trạng thái IPC environment (alive/stopped)
+│
+├── twitter/
+│ └── actions.jsonl ← Log hành động Twitter (từng dòng = 1 action)
+│
+├── reddit/
+│ └── actions.jsonl ← Log hành động Reddit
+│
+├── ipc_commands/ ← Flask ghi lệnh vào đây
+│ └── {uuid}.json
+│
+├── ipc_responses/ ← OASIS ghi response vào đây
+│ └── {uuid}.json
+│
+├── twitter_simulation.db ← SQLite database của OASIS Twitter
+└── reddit_simulation.db ← SQLite database của OASIS Reddit
+```
+
+### 11.2 Thư mục Report
+
+```
+uploads/reports/{report_id}/
+│
+├── report.json ← Full report object (JSON)
+├── report.md ← Nội dung Markdown
+│
+├── agent_log.jsonl ← Log chi tiết từng bước ReACT (JSONL)
+└── console_log.txt ← Log console text (INFO/WARNING)
+```
+
+---
+
+## 12. Luồng dữ liệu end-to-end
+
+### 12.1 Sơ đồ tổng hợp
+
+```
+[USER]
+ │ Upload tài liệu + yêu cầu mô phỏng
+ ↓
+[TextProcessor]
+ │ document_text (cleaned string)
+ ↓
+[Zep Graph Builder] ← (pipeline riêng, chạy trước)
+ │ Graph {Nodes + Edges}
+ ↓
+[ZepEntityReader.filter_defined_entities()]
+ │ List[EntityNode] — 47 entities
+ │ (mỗi entity: uuid, name, labels, summary, related_edges, related_nodes)
+ ↓
+[OasisProfileGenerator.generate_profiles_from_entities()]
+ │ ← Zep Vector Search (parallel, cho từng entity)
+ │ ← LLM (parallel, 3 luồng, retry 3 lần / rule-based fallback)
+ │ List[OasisAgentProfile] — 47 profiles
+ │ → reddit_profiles.json (realtime save)
+ │ → twitter_profiles.csv (realtime save)
+ ↓
+[SimulationConfigGenerator.generate_config()]
+ │ ← LLM (4 bước tuần tự: time → event → agents×3 batch → platform)
+ │ SimulationParameters
+ │ → simulation_config.json
+ ↓
+[SimulationRunner.start_simulation()]
+ │ subprocess.Popen(run_parallel_simulation.py --config ...)
+ │
+ │ [OASIS Script — Subprocess]
+ │ ├── Read: reddit_profiles.json + twitter_profiles.csv
+ │ ├── Read: simulation_config.json
+ │ ├── Init: Twitter Environment + Reddit Environment
+ │ └── Loop 72 rounds:
+ │ └── N agents active per round
+ │ └── LLM(persona + timeline) → action → log
+ │ → twitter/actions.jsonl
+ │ → reddit/actions.jsonl
+ │
+ │ [Monitor Thread — Flask]
+ │ Đọc actions.jsonl mỗi 2s → cập nhật run_state.json
+ ↓
+[ReportAgent.generate_report()]
+ │ ← Zep Graph (query statistics + facts)
+ │ ← LLM (Planning: 1 call)
+ │ ← LLM ReACT (Per section: 3-5 tool calls + 1 final answer)
+ │ ← ZepTools (insight_forge / panorama_search / quick_search)
+ │ ← SimulationRunner.interview_agents_batch() (qua IPC nếu env alive)
+ │ → report.md
+ │ → agent_log.jsonl
+ ↓
+[USER nhận báo cáo]
+```
+
+### 12.2 Trạng thái lifecycle của SimulationState
+
+```
+CREATED
+ ↓ (prepare_simulation bắt đầu)
+PREPARING
+ ↓ (tất cả 3 phase chuẩn bị xong)
+READY
+ ↓ (start_simulation được gọi)
+RUNNING ←→ PAUSED (tính năng tương lai)
+ ↓ (mô phỏng tự nhiên kết thúc)
+COMPLETED
+ ↓ (hoặc)
+FAILED ← (bất kỳ lỗi nghiêm trọng nào)
+STOPPED ← (người dùng chủ động dừng)
+```
+
+### 12.3 Trạng thái lifecycle của RunnerStatus
+
+```
+IDLE
+ ↓ (start_simulation)
+STARTING
+ ↓ (subprocess bắt đầu)
+RUNNING
+ ↓ (simulation_end event trong actions.jsonl)
+COMPLETED
+
+Hoặc:
+RUNNING → STOPPING → STOPPED (người dùng stop)
+RUNNING → FAILED (subprocess crash)
+```
+
+---
+
+*Báo cáo này được tạo bởi Claude Sonnet 4.6 dựa trên phân tích mã nguồn MiroFish.*
+*Cập nhật lần cuối: 2026-05-04*
diff --git a/backend/app/services/graph_builder.py b/backend/app/services/graph_builder.py
index bbff79d1..d1deeb42 100644
--- a/backend/app/services/graph_builder.py
+++ b/backend/app/services/graph_builder.py
@@ -4,6 +4,7 @@ API 2: Sử dụng Zep API để xây dựng một Standalone Graph (Đồ thị
"""
import os
+import re # changed
import uuid
import time
import threading
@@ -330,27 +331,47 @@ class GraphBuilderService:
]
# Khởi chạy gửi cho Zep Server
- try:
- # UPLOAD BATCH TO ZEP - Sends batch of episodes for entity extraction
- batch_result = self.client.graph.add_batch(
- graph_id=graph_id,
- episodes=episodes
- )
-
- # Cập nhật và thu thập lại UUID của các Episode được trả về sau khi tạo mới
- 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)
-
- # Cài thời gian chờ (delay) nhỏ để tránh rate-limit bị quá tải số lượng requests
- time.sleep(1)
-
- except Exception as e:
- if progress_callback:
- progress_callback(f"Failed to send batch {batch_num}: {str(e)}", 0)
- raise
+ max_retries = 10 # changed
+ for attempt in range(max_retries): # changed
+ try:
+ # UPLOAD BATCH TO ZEP - Sends batch of episodes for entity extraction
+ batch_result = self.client.graph.add_batch(
+ graph_id=graph_id,
+ episodes=episodes
+ )
+
+ # Cập nhật và thu thập lại UUID của các Episode được trả về sau khi tạo mới
+ 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)
+
+ # Cài thời gian chờ (delay) nhỏ để tránh rate-limit bị quá tải số lượng requests
+ time.sleep(3)
+ break # changed: thoát retry loop nếu thành công
+
+ except Exception as e: # changed
+ err_str = str(e)
+ if "episode usage limit" in err_str or ("status_code: 429" in err_str): # changed: bắt lỗi rate-limit
+ # Đọc thời điểm reset từ error message để biết cần chờ bao lâu
+ reset_match = re.search(r"x-ratelimit-reset['\"]:\s*['\"]?(\d+)", err_str) # changed
+ if reset_match: # changed
+ wait_seconds = max(int(reset_match.group(1)) - int(time.time()) + 2, 5) # changed
+ else: # changed
+ wait_seconds = 20 # changed: fallback 12s (5 calls/phút → cách nhau 12s)
+ if progress_callback: # changed
+ progress_callback( # changed
+ f"Rate limited by Zep. Waiting {wait_seconds}s then retry (attempt {attempt + 1}/{max_retries})...", # changed
+ (i + len(batch_chunks)) / total_chunks # changed
+ ) # changed
+ time.sleep(wait_seconds) # changed
+ else: # changed: lỗi khác thì raise luôn, không retry
+ if progress_callback:
+ progress_callback(f"Failed to send batch {batch_num}: {err_str}", 0)
+ raise # changed
+ else: # changed: for...else — chạy khi hết max_retries mà vẫn chưa break
+ raise Exception(f"Batch {batch_num} failed after {max_retries} retries due to rate limiting") # changed
return episode_uuids
@@ -358,7 +379,7 @@ class GraphBuilderService:
self,
episode_uuids: List[str],
progress_callback: Optional[Callable] = None,
- timeout: int = 600
+ timeout: int = 1000
):
"""Chạy vòng lặp để kiểm tra và chờ cho tới khi mọi Episode (các khối Text) đều hoàn tất quá trình process từ hệ thống"""
if not episode_uuids:
diff --git a/backend/app/services/oasis_profile_generator.py b/backend/app/services/oasis_profile_generator.py
index b37284cf..d162b508 100644
--- a/backend/app/services/oasis_profile_generator.py
+++ b/backend/app/services/oasis_profile_generator.py
@@ -1,7 +1,21 @@
"""
-Trình tạo tạo ra Profile Agent (Hồ sơ Nhân vật) cho Agent bằng Framework OASIS
+Trình tạo Profile Agent (Hồ sơ Nhân vật) cho Agent bằng Framework OASIS
Chuyển đổi dữ liệu Thực thể được Query từ Zep ra chuẩn định dạng của các Agent tham gia vào mạng
+Vị trí trong pipeline:
+─────────────────────────────────────────────────────────────────────────────
+ simulation_manager.prepare_simulation() [Giai đoạn 2]
+ └─ OasisProfileGenerator.generate_profiles_from_entities()
+ └─ generate_profile_from_entity() [mỗi entity 1 lần]
+ ├─ _build_entity_context() [tổng hợp ngữ cảnh]
+ │ └─ _search_zep_for_entity() [vector search]
+ └─ _generate_profile_with_llm() [gọi LLM]
+ └─ _generate_profile_rule_based() [fallback]
+─────────────────────────────────────────────────────────────────────────────
+
+Input: List[EntityNode] từ ZepEntityReader (zep_entity_reader.py)
+Output: List[OasisAgentProfile] → ghi ra reddit_profiles.json / twitter_profiles.csv
+
Nâng cấp cải thiện:
1. Kết hợp dùng chức năng Search trên Zep để lấy Profile giàu sắc thái
2. Gen các cấu hình về tính cách một cách sắc xảo và cực sâu cho Prompt
@@ -26,51 +40,73 @@ from .zep_entity_reader import EntityNode, ZepEntityReader
logger = get_logger('mirofish.oasis_profile')
+# ==============================================================================
+# DATACLASS: OasisAgentProfile — Cấu trúc hồ sơ 1 agent trong OASIS
+# ==============================================================================
+# Mỗi EntityNode từ Zep → 1 OasisAgentProfile sau khi qua bước generate.
+# Class này đóng vai trò "adapter": chuẩn hoá dữ liệu thành 2 format
+# mà OASIS yêu cầu (Twitter CSV / Reddit JSON).
+#
+# Quan hệ các field với OASIS:
+# user_id → OASIS dùng để map agent trong agent_graph.get_agent()
+# user_name → username trên mạng xã hội ảo (không có dấu cách)
+# bio → thông tin hiển thị công khai trên trang cá nhân
+# persona → system prompt bí mật điều khiển mọi hành vi của LLM agent
+# ==============================================================================
+
@dataclass
class OasisAgentProfile:
"""Cấu trúc của Dataclass Profile Agent qua quy định của OASIS"""
- # Các Field thông dụng (Common data)
- user_id: int
- user_name: str
- name: str
- bio: str
- persona: str
-
- # Chọn bổ sung (Tùy chọn) - Thông số nền tảng Reddit (Karma)
- karma: int = 1000
-
- # Chọn bổ sung (Tùy chọn) - Thông số nền tảng Twitter
- friend_count: int = 100
- follower_count: int = 150
- statuses_count: int = 500
-
- # Một số Thông tin Data cá nhân bổ sung (Bóp để tăng tính Thực tế nếu LLM sinh ra)
+
+ # --- Định danh bắt buộc ---
+ user_id: int # Index số nguyên, bắt đầu từ 0 (bắt buộc để OASIS map đúng agent)
+ user_name: str # Username không dấu cách, ví dụ: "nguyen_van_a_392" (sinh từ _generate_username)
+ name: str # Tên thật hiển thị, ví dụ: "Nguyễn Văn A"
+
+ # --- Nội dung agent ---
+ bio: str # Tiểu sử ngắn (~150-200 ký tự), hiển thị công khai trên trang cá nhân
+ persona: str # Mô tả nhân cách chi tiết (~2000 từ), được nhét vào system prompt của agent LLM
+ # Đây là thứ THỰC SỰ điều khiển agent nghĩ và nói gì
+
+ # --- Thông số mạng xã hội (tác động đến "trọng số" của agent trong OASIS) ---
+ karma: int = 1000 # Reddit: điểm uy tín (cao = post được nhiều người thấy hơn)
+ friend_count: int = 100 # Twitter: số người đang follow
+ follower_count: int = 150 # Twitter: số người follow mình
+ statuses_count: int = 500 # Twitter: số tweet đã đăng (độ hoạt động)
+
+ # --- Thông tin cá nhân bổ sung (tùy chọn, làm phong phú persona) ---
age: Optional[int] = None
- gender: Optional[str] = None
- mbti: Optional[str] = None
+ gender: Optional[str] = None # "male", "female", hoặc "other" (tổ chức)
+ mbti: Optional[str] = None # Ví dụ: "INTJ", "ENFP" — gợi ý cách LLM phản ứng
country: Optional[str] = None
profession: Optional[str] = None
- interested_topics: List[str] = field(default_factory=list)
-
- # Lịch sử thông tin entity gốc được lấy
- source_entity_uuid: Optional[str] = None
- source_entity_type: Optional[str] = None
-
+ interested_topics: List[str] = field(default_factory=list) # Các chủ đề agent quan tâm
+
+ # --- Metadata truy vết nguồn gốc ---
+ source_entity_uuid: Optional[str] = None # UUID của EntityNode gốc trong Zep
+ source_entity_type: Optional[str] = None # Loại entity (ví dụ: "Person", "Organization")
+
created_at: str = field(default_factory=lambda: datetime.now().strftime("%Y-%m-%d"))
-
+
def to_reddit_format(self) -> Dict[str, Any]:
- """Convert trả ra cho định dạng Agent reddit"""
+ """
+ Xuất profile thành dict theo chuẩn Reddit của OASIS.
+
+ Khác biệt với to_twitter_format(): có karma thay vì friend/follower_count.
+ Các field tùy chọn (age, gender, ...) chỉ được thêm vào nếu có giá trị
+ để tránh null gây lỗi trong OASIS engine.
+ """
profile = {
"user_id": self.user_id,
- "username": self.user_name, # Source mã của OASIS Library yêu cầu không có dấu "_" cho param username
+ "username": self.user_name, # OASIS yêu cầu không có dấu "_" là không có vấn đề nhưng không được có khoảng trắng
"name": self.name,
"bio": self.bio,
"persona": self.persona,
"karma": self.karma,
"created_at": self.created_at,
}
-
- # Merge Thông tin Data Profile Cá nhân (Nếu CÓ)
+
+ # Chỉ thêm field nếu có giá trị — OASIS không xử lý được None
if self.age:
profile["age"] = self.age
if self.gender:
@@ -83,14 +119,19 @@ class OasisAgentProfile:
profile["profession"] = self.profession
if self.interested_topics:
profile["interested_topics"] = self.interested_topics
-
+
return profile
-
+
def to_twitter_format(self) -> Dict[str, Any]:
- """Convert trả ra cho định dạng Agent Twitter"""
+ """
+ Xuất profile thành dict theo chuẩn Twitter của OASIS.
+
+ Khác biệt với to_reddit_format(): có friend_count, follower_count, statuses_count
+ thay vì karma. Cả hai format đều dùng chung bio và persona.
+ """
profile = {
"user_id": self.user_id,
- "username": self.user_name, # Tương tự như trên
+ "username": self.user_name,
"name": self.name,
"bio": self.bio,
"persona": self.persona,
@@ -99,8 +140,7 @@ class OasisAgentProfile:
"statuses_count": self.statuses_count,
"created_at": self.created_at,
}
-
- # Merge Thông tin Data Profile Cả nhân
+
if self.age:
profile["age"] = self.age
if self.gender:
@@ -113,11 +153,11 @@ class OasisAgentProfile:
profile["profession"] = self.profession
if self.interested_topics:
profile["interested_topics"] = self.interested_topics
-
+
return profile
-
+
def to_dict(self) -> Dict[str, Any]:
- """Quy đổi thành toàn bộ Dictionary Cấu Trúc Khép Kín """
+ """Xuất toàn bộ profile thành dict không lọc (kể cả source_entity_uuid/type)."""
return {
"user_id": self.user_id,
"user_name": self.user_name,
@@ -140,107 +180,134 @@ class OasisAgentProfile:
}
+# ==============================================================================
+# CLASS: OasisProfileGenerator — Sinh agent profile từ EntityNode
+# ==============================================================================
+# Stateful: giữ OpenAI client, Zep client, và graph_id trong suốt vòng sống.
+# Được khởi tạo 1 lần trong prepare_simulation() rồi dùng để sinh toàn bộ profiles.
+# ==============================================================================
+
class OasisProfileGenerator:
"""
Trình Gen Profile cho Simulation (Hệ OASIS)
-
+
Sử dụng các Node Entity lấy được từ ZEP -> OASIS Mocks cho Simulation Agent
-
+
Các Option Cải Tiến Tối Ưu Tích Hợp:
1. Có liên kết với Server Zep API cho bước Query Dữ liệu từ Vector Database
- 2. Tập trung Mô tả Tiểu sửa (Background) (Nhấn mạnh Nghề nghiệp/Tính cách/StatusMXH/vvv)
+ 2. Tập trung Mô tả Tiểu sử (Background) (Nhấn mạnh Nghề nghiệp/Tính cách/StatusMXH/...)
3. Ngắt rời loại Person ra bên ngoài để nhận thức Phân Cấp Group
"""
-
- # 16 tính cách của Con người (Quy Chuẩn)
+
+ # 16 loại tính cách MBTI — dùng khi sinh ngẫu nhiên hoặc fallback
MBTI_TYPES = [
"INTJ", "INTP", "ENTJ", "ENTP",
"INFJ", "INFP", "ENFJ", "ENFP",
"ISTJ", "ISFJ", "ESTJ", "ESFJ",
"ISTP", "ISFP", "ESTP", "ESFP"
]
-
- # List mảng quốc tịch Cơ Bản
+
+ # Danh sách quốc tịch fallback khi LLM không sinh được
COUNTRIES = [
- "Vietnam", "China", "US", "UK", "Japan", "Germany", "France",
+ "Vietnam", "China", "US", "UK", "Japan", "Germany", "France",
"Canada", "Australia", "Brazil", "India", "South Korea"
]
-
- # Thực thể nhận biết là người 1 mình (Độc Lập, 1 Person)
+
+ # Loại entity được xem là CÁ NHÂN → dùng _build_individual_persona_prompt()
+ # LLM sẽ sinh persona theo góc nhìn 1 người cụ thể (tuổi, nghề nghiệp, MBTI riêng)
INDIVIDUAL_ENTITY_TYPES = [
- "student", "alumni", "professor", "person", "publicfigure",
+ "student", "alumni", "professor", "person", "publicfigure",
"expert", "faculty", "official", "journalist", "activist"
]
-
- # Thực Thể được xem là một tổ chức
+
+ # Loại entity được xem là TỔ CHỨC/NHÓM → dùng _build_group_persona_prompt()
+ # LLM sẽ sinh persona như một tài khoản chính thức đại diện tổ chức
+ # (age=30 cố định, gender="other")
GROUP_ENTITY_TYPES = [
- "university", "governmentagency", "organization", "ngo",
+ "university", "governmentagency", "organization", "ngo",
"mediaoutlet", "company", "institution", "group", "community"
]
-
+
def __init__(
- self,
+ self,
api_key: Optional[str] = None,
base_url: Optional[str] = None,
model_name: Optional[str] = None,
zep_api_key: Optional[str] = None,
graph_id: Optional[str] = None
):
+ # --- Khởi tạo OpenAI client (dùng để gọi LLM sinh persona) ---
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("Không tìm thấy LLM_API_KEY")
-
+
self.client = OpenAI(
api_key=self.api_key,
base_url=self.base_url
)
+
+ # Metadata dùng cho hệ thống tính cost API (llm_cost.py)
self._runtime_metadata: Dict[str, Any] = {
"component": "oasis_profile_generator",
"phase": "generate_profiles",
}
-
- # Kết nối lên trên ZEP Database Search Context
+
+ # --- Khởi tạo Zep client (dùng để vector search tìm thêm context) ---
+ # graph_id được truyền vào từ simulation_manager để tất cả search đều
+ # trỏ đúng vào graph của simulation hiện tại
self.zep_api_key = zep_api_key or Config.ZEP_API_KEY
self.zep_client = None
self.graph_id = graph_id
-
+
if self.zep_api_key:
try:
self.zep_client = Zep(api_key=self.zep_api_key)
except Exception as e:
+ # Không raise — Zep search là tính năng bổ sung, không bắt buộc
logger.warning(f"Failed to initialize Zep client: {e}")
-
+
+ # --------------------------------------------------------------------------
+ # PUBLIC: generate_profile_from_entity — Entry point sinh 1 profile đơn lẻ
+ # --------------------------------------------------------------------------
+
def generate_profile_from_entity(
- self,
- entity: EntityNode,
+ self,
+ entity: EntityNode,
user_id: int,
use_llm: bool = True
) -> OasisAgentProfile:
"""
- Bắt đầu Gen Profile từ Entity được móc từ data từ zep
-
+ Chuyển đổi 1 EntityNode thành 1 OasisAgentProfile.
+
+ Luồng xử lý:
+ EntityNode
+ ↓
+ _build_entity_context() → gom tất cả context thành 1 chuỗi
+ ↓
+ _generate_profile_with_llm() hoặc _generate_profile_rule_based()
+ ↓
+ OasisAgentProfile
+
Args:
- entity: Thực thể Zep
- user_id: Số ID để map (sử dụng trên OASIS)
- use_llm: Chọn bật tắt xem tạo Profile có dùng gen nhân vật bằng LLM
-
+ entity: EntityNode đọc từ Zep (có related_edges và related_nodes)
+ user_id: Index số nguyên, bắt đầu từ 0, bắt buộc cho OASIS
+ use_llm: True = gọi LLM (chậm, chất lượng cao); False = rule-based (nhanh, đơn giản)
+
Returns:
- OasisAgentProfile
+ OasisAgentProfile đã điền đầy đủ thông tin
"""
entity_type = entity.get_entity_type() or "Entity"
-
- # Mức cơ bản thông tin
+
name = entity.name
user_name = self._generate_username(name)
-
- # Build các Context thông tin liên quan lại
+
+ # Tổng hợp toàn bộ ngữ cảnh về entity (attributes + edges + Zep search)
context = self._build_entity_context(entity)
-
+
if use_llm:
- # Gửi Prompt lên LLM
profile_data = self._generate_profile_with_llm(
entity_name=name,
entity_type=entity_type,
@@ -249,14 +316,16 @@ class OasisProfileGenerator:
context=context
)
else:
- # Chạy hàm Auto rule nếu LLm tắt
+ # Fallback thủ công — không gọi LLM, dùng template cứng
profile_data = self._generate_profile_rule_based(
entity_name=name,
entity_type=entity_type,
entity_summary=entity.summary,
entity_attributes=entity.attributes
)
-
+
+ # Ghép kết quả LLM vào OasisAgentProfile
+ # .get(field, fallback) để an toàn nếu LLM bỏ sót field nào
return OasisAgentProfile(
user_id=user_id,
user_name=user_name,
@@ -276,63 +345,98 @@ class OasisProfileGenerator:
source_entity_uuid=entity.uuid,
source_entity_type=entity_type,
)
-
+
+ # --------------------------------------------------------------------------
+ # PRIVATE: _generate_username — Tạo username duy nhất từ tên entity
+ # --------------------------------------------------------------------------
+
def _generate_username(self, name: str) -> str:
- """Thêm chức năng Generate Username username ngẫu nhiên"""
- # Hút bỏ khoảng trống và dấu đặc biệt
+ """
+ Tạo username hợp lệ từ tên thật.
+
+ Quy trình:
+ 1. Lowercase + thay khoảng trắng bằng "_"
+ 2. Giữ chỉ ký tự alphanumeric và "_"
+ 3. Thêm suffix số ngẫu nhiên 3 chữ số để tránh trùng
+
+ Ví dụ: "Nguyễn Văn A" → "nguyn_vn_a_392"
+ (ký tự Unicode bị strip vì isalnum() chỉ giữ ASCII)
+ """
username = name.lower().replace(" ", "_")
username = ''.join(c for c in username if c.isalnum() or c == '_')
-
- # Chèn thêm hậu tố cho bớt đụng hàng
+
suffix = random.randint(100, 999)
return f"{username}_{suffix}"
-
+
+ # --------------------------------------------------------------------------
+ # PRIVATE: _search_zep_for_entity — Vector search song song trên Zep
+ # --------------------------------------------------------------------------
+ # Đây là bước "tăng cường" context: tìm thêm facts và summaries liên quan
+ # đến entity trong Zep vector database bằng semantic search.
+ #
+ # Tại sao cần? related_edges trong EntityNode chỉ có edges kết nối trực tiếp.
+ # Zep vector search có thể tìm được thông tin liên quan theo ngữ nghĩa,
+ # kể cả những facts không có edge trực tiếp đến entity này.
+ #
+ # Tại sao cần chạy song song?
+ # Zep API chưa hỗ trợ tìm cả nodes lẫn edges trong 1 request →
+ # dùng ThreadPoolExecutor 2 workers để gọi song song, giảm latency từ 2x → 1x.
+ # --------------------------------------------------------------------------
+
def _search_zep_for_entity(self, entity: EntityNode) -> Dict[str, Any]:
"""
- Dùng hỗn hợp lệnh Query DB Vector qua Zep để lấy các fact/sự kiện liên quan về 1 thực thể.
-
- Vì Zep chưa hỗ trợ hỗn hợp cả 2 cùng một lúc, nên cần tìm song song từ Edge và Node sau đó gộp kết quả.
-
+ Tìm kiếm semantic trong Zep Vector DB để bổ sung context cho entity.
+
+ Gọi 2 loại search song song:
+ - scope="edges": tìm các fact/quan hệ liên quan đến entity
+ - scope="nodes": tìm các entity khác có liên quan theo ngữ nghĩa
+
+ Mỗi search có retry 3 lần với exponential backoff (2s → 4s → 8s).
+
Args:
- entity: Đầu cắm Thực Thể Node
-
+ entity: EntityNode cần tìm thêm context
+
Returns:
- Dictionary gồm facts, node_summaries, context
+ Dict với:
+ - facts: List[str] các fact tìm được từ edge search (loại trùng với related_edges)
+ - node_summaries: List[str] summaries của các entity liên quan
+ - context: Chuỗi text tổng hợp để nhét vào LLM prompt
"""
import concurrent.futures
-
+
+ # Nếu không có Zep client hoặc graph_id → trả về rỗng, không làm gì
if not self.zep_client:
return {"facts": [], "node_summaries": [], "context": ""}
-
+
entity_name = entity.name
-
+
results = {
"facts": [],
"node_summaries": [],
"context": ""
}
-
- # Yêu cầu graph_id mới truy vấn được
+
if not self.graph_id:
logger.debug(f"Skipping Zep search: graph_id not set")
return results
-
+
+ # Query tổng quát để Zep semantic search trả về nhiều kết quả nhất
comprehensive_query = f"Provide all facts, activities, relationships, and context about: {entity_name}"
-
+
def search_edges():
- """Lookup cạnh relations - Kết hợp cơ chế retry"""
+ """Tìm các fact/quan hệ qua edge search — có retry."""
max_retries = 3
last_exception = None
delay = 2.0
-
+
for attempt in range(max_retries):
try:
return self.zep_client.graph.search(
query=comprehensive_query,
graph_id=self.graph_id,
- limit=30,
+ limit=30, # Lấy tối đa 30 facts
scope="edges",
- reranker="rrf"
+ reranker="rrf" # Reciprocal Rank Fusion — kết hợp nhiều ranking strategies
)
except Exception as e:
last_exception = e
@@ -343,19 +447,19 @@ class OasisProfileGenerator:
else:
logger.debug(f"Zep Edge search entirely failed after {max_retries} attempts: {e}")
return None
-
+
def search_nodes():
- """Lookup mảng Node entity tóm tắt - Kết hợp cơ chế retry"""
+ """Tìm các entity liên quan qua node search — có retry."""
max_retries = 3
last_exception = None
delay = 2.0
-
+
for attempt in range(max_retries):
try:
return self.zep_client.graph.search(
query=comprehensive_query,
graph_id=self.graph_id,
- limit=20,
+ limit=20, # Lấy tối đa 20 node summaries
scope="nodes",
reranker="rrf"
)
@@ -368,26 +472,25 @@ class OasisProfileGenerator:
else:
logger.debug(f"Zep Node search entirely failed after {max_retries} attempts: {e}")
return None
-
+
try:
- # Cho chạy cả task Cạnh và Node song song
+ # Chạy song song 2 search — giảm thời gian chờ từ ~2T xuống ~T
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor:
edge_future = executor.submit(search_edges)
node_future = executor.submit(search_nodes)
-
- # Fetch result trả về
+
edge_result = edge_future.result(timeout=30)
node_result = node_future.result(timeout=30)
-
- # Quản lý kết quả fact của các cạnh Edge
- all_facts = set()
+
+ # Xử lý kết quả edge search → trích fact string
+ all_facts = set() # Set để tự dedup
if edge_result and hasattr(edge_result, 'edges') and edge_result.edges:
for edge in edge_result.edges:
if hasattr(edge, 'fact') and edge.fact:
all_facts.add(edge.fact)
results["facts"] = list(all_facts)
-
- # Quản lý kết quả tên thực thể và summary của quá trình search Node
+
+ # Xử lý kết quả node search → trích summary và tên entity liên quan
all_summaries = set()
if node_result and hasattr(node_result, 'nodes') and node_result.nodes:
for node in node_result.nodes:
@@ -396,36 +499,69 @@ class OasisProfileGenerator:
if hasattr(node, 'name') and node.name and node.name != entity_name:
all_summaries.add(f"Related Entities: {node.name}")
results["node_summaries"] = list(all_summaries)
-
- # Tổng hợp ra 1 chuỗi Context bao quanh
+
+ # Ghép thành 1 chuỗi context để truyền vào LLM prompt
context_parts = []
if results["facts"]:
context_parts.append("Facts & Infomation:\n" + "\n".join(f"- {f}" for f in results["facts"][:20]))
if results["node_summaries"]:
context_parts.append("Related Entities:\n" + "\n".join(f"- {s}" for s in results["node_summaries"][:10]))
results["context"] = "\n\n".join(context_parts)
-
+
logger.info(f"Zep unified search completed: {entity_name}, fetched {len(results['facts'])} facts, {len(results['node_summaries'])} related nodes")
-
+
except concurrent.futures.TimeoutError:
logger.warning(f"Zep Retrieval Time-Out ({entity_name})")
except Exception as e:
logger.warning(f"Zep Retrieval Failed ({entity_name}): {e}")
-
+
return results
-
+
+ # --------------------------------------------------------------------------
+ # PRIVATE: _build_entity_context — Tổng hợp 4 nguồn context cho LLM
+ # --------------------------------------------------------------------------
+ # Hàm này gom tất cả thông tin biết được về entity thành 1 chuỗi dài
+ # để nhét vào LLM prompt. Có 4 nguồn theo thứ tự ưu tiên:
+ #
+ # 1. attributes — thuộc tính key-value trực tiếp từ Zep node
+ # 2. related_edges — facts/quan hệ từ các edge đã enrich (tránh trùng lặp với nguồn 4)
+ # 3. related_nodes — mô tả ngắn của các entity lân cận
+ # 4. Zep vector search — facts bổ sung từ semantic search (lọc bỏ trùng với nguồn 2)
+ # --------------------------------------------------------------------------
+
def _build_entity_context(self, entity: EntityNode) -> str:
"""
- Nối tất cả info thu được liên quan thành 1 chuỗi Context bao quanh hoàn chỉnh cho Entity
-
- Nó sẽ lấy:
- 1. Context từ các cạnh hiện tại đã gắn Entity (Dữ liệu về relation/fact)
- 2. Mô tả sơ lược thêm của các Node dính liền
- 3. Cuối cùng nhồi thêm những thứ moi được từ quá trình chạy Zep search hỗn hợp bên trên
+ Gom tất cả thông tin có thể biết về entity thành 1 chuỗi context hoàn chỉnh.
+
+ Output chuỗi được cắt còn tối đa 3000 ký tự trước khi nhét vào LLM prompt.
+
+ Cấu trúc output (nếu đủ dữ liệu):
+ ### Entity Attributes
+ - key1: value1
+ - key2: value2
+
+ ### Facts & Relationships
+ - A làm việc tại B
+ - C là sinh viên của D
+
+ ### Related Entity Info
+ - **Bộ GD** (Organization): Cơ quan quản lý giáo dục...
+
+ ### Facts retrieved via ZEP
+ - fact mới không trùng với phần trên
+
+ ### Entity nodes retrieved via Zep
+ - summary của entity liên quan
+
+ Args:
+ entity: EntityNode đã enrich với related_edges và related_nodes
+
+ Returns:
+ Chuỗi context markdown dùng trong LLM prompt
"""
context_parts = []
-
- # 1. Thu thập Attributes/Properties của node nếu có
+
+ # --- Nguồn 1: Attributes trực tiếp của node ---
if entity.attributes:
attrs = []
for key, value in entity.attributes.items():
@@ -433,70 +569,85 @@ class OasisProfileGenerator:
attrs.append(f"- {key}: {value}")
if attrs:
context_parts.append("### Entity Attributes\n" + "\n".join(attrs))
-
- # 2. Add các facts và mô phỏng cạnh (Relationship/Facts)
+
+ # --- Nguồn 2: Facts từ các edge đã enrich ---
+ # Lưu vào set để sau đó lọc trùng với kết quả Zep search (nguồn 4)
existing_facts = set()
if entity.related_edges:
relationships = []
- for edge in entity.related_edges: # Khum bị giới hạn SL
+ for edge in entity.related_edges:
fact = edge.get("fact", "")
edge_name = edge.get("edge_name", "")
direction = edge.get("direction", "")
-
+
if fact:
relationships.append(f"- {fact}")
existing_facts.add(fact)
elif edge_name:
+ # Nếu không có fact text, tự tạo dạng arrow diagram
if direction == "outgoing":
relationships.append(f"- {entity.name} --[{edge_name}]--> (Related Entity)")
else:
relationships.append(f"- (Related Entity) --[{edge_name}]--> {entity.name}")
-
+
if relationships:
context_parts.append("### Facts & Relationships\n" + "\n".join(relationships))
-
- # 3. Kẹp chi tiết miêu tả về node anh em cạnh bên
+
+ # --- Nguồn 3: Thông tin cơ bản của các node lân cận ---
if entity.related_nodes:
related_info = []
- for node in entity.related_nodes: # Không block giới hạn số lượng
+ for node in entity.related_nodes:
node_name = node.get("name", "")
node_labels = node.get("labels", [])
node_summary = node.get("summary", "")
-
- # Bỏ nhãn mặc định khỏi string xuất ra
+
+ # Loại bỏ label mặc định để chỉ hiện loại cụ thể
custom_labels = [l for l in node_labels if l not in ["Entity", "Node"]]
label_str = f" ({', '.join(custom_labels)})" if custom_labels else ""
-
+
if node_summary:
related_info.append(f"- **{node_name}**{label_str}: {node_summary}")
else:
related_info.append(f"- **{node_name}**{label_str}")
-
+
if related_info:
context_parts.append("### Related Entity Info\n" + "\n".join(related_info))
-
- # 4. Sử dụng kết quả Query Search từ hàm zep
+
+ # --- Nguồn 4: Vector search từ Zep (bổ sung, không trùng nguồn 2) ---
zep_results = self._search_zep_for_entity(entity)
-
+
if zep_results.get("facts"):
- # Lọc bớt cặn trùng lắp: không add những Fact đã có ở mục số 2
+ # Lọc bỏ facts đã có trong nguồn 2 để tránh lặp lại
new_facts = [f for f in zep_results["facts"] if f not in existing_facts]
if new_facts:
context_parts.append("### Facts retrieved via ZEP\n" + "\n".join(f"- {f}" for f in new_facts[:15]))
-
+
if zep_results.get("node_summaries"):
context_parts.append("### Entity nodes retrieved via Zep\n" + "\n".join(f"- {s}" for s in zep_results["node_summaries"][:10]))
-
+
return "\n\n".join(context_parts)
-
+
+ # --------------------------------------------------------------------------
+ # PRIVATE: _is_individual_entity / _is_group_entity — Phân loại entity type
+ # --------------------------------------------------------------------------
+
def _is_individual_entity(self, entity_type: str) -> bool:
- """KTra và True cho các dạng người Single Person"""
+ """Trả về True nếu entity_type thuộc danh sách cá nhân (INDIVIDUAL_ENTITY_TYPES)."""
return entity_type.lower() in self.INDIVIDUAL_ENTITY_TYPES
-
+
def _is_group_entity(self, entity_type: str) -> bool:
- """KTra xem thực thể hiện tại là Group/Media/Company ..."""
+ """Trả về True nếu entity_type thuộc danh sách tổ chức (GROUP_ENTITY_TYPES)."""
return entity_type.lower() in self.GROUP_ENTITY_TYPES
-
+
+ # --------------------------------------------------------------------------
+ # PRIVATE: _generate_profile_with_llm — Gọi LLM sinh profile, có retry và JSON repair
+ # --------------------------------------------------------------------------
+ # Đây là hàm LLM chính. Có 3 lớp bảo vệ:
+ # Lớp 1: Retry 3 lần nếu API call thất bại
+ # Lớp 2: Sửa JSON bị cắt gãy (finish_reason == 'length')
+ # Lớp 3: Fallback sang rule-based nếu tất cả LLM attempts thất bại
+ # --------------------------------------------------------------------------
+
def _generate_profile_with_llm(
self,
entity_name: str,
@@ -506,15 +657,25 @@ class OasisProfileGenerator:
context: str
) -> Dict[str, Any]:
"""
- Dùng LLM cấp lại Profile mô phỏng tính cách cụ thể và rõ nét nhất
-
- Kiểm tra đầu vào Entity type để chia nhánh:
- - Cá nhân: Tạo setting miêu tả cá nhân, công việc riêng
- - Tập Thể/Cơ quan/Tổ chức: Tạo Profile cho 1 tài khoản đại điện tổ chức đó
+ Gọi LLM để sinh profile dict với bio, persona, age, gender, mbti, ...
+
+ Quyết định dùng prompt nào dựa vào entity_type:
+ - Cá nhân (student/person/...) → _build_individual_persona_prompt()
+ - Tổ chức (university/ngo/...) → _build_group_persona_prompt()
+ - Không xác định → cũng dùng group prompt
+
+ Retry logic:
+ - Lần 1: temperature=0.7
+ - Lần 2 (nếu lỗi): temperature=0.6 (ít ngẫu nhiên hơn → dễ parse JSON hơn)
+ - Lần 3 (nếu lỗi): temperature=0.5
+
+ Nếu hết 3 lần vẫn fail → fallback sang _generate_profile_rule_based()
+
+ Returns:
+ Dict với ít nhất: {"bio": ..., "persona": ...}
"""
-
is_individual = self._is_individual_entity(entity_type)
-
+
if is_individual:
prompt = self._build_individual_persona_prompt(
entity_name, entity_type, entity_summary, entity_attributes, context
@@ -524,10 +685,9 @@ class OasisProfileGenerator:
entity_name, entity_type, entity_summary, entity_attributes, context
)
- # Retry liên tục vòng lặp nếu LLM timeout hoặc fail
max_attempts = 3
last_error = None
-
+
for attempt in range(max_attempts):
try:
response = create_tracked_chat_completion(
@@ -537,127 +697,150 @@ class OasisProfileGenerator:
{"role": "system", "content": self._get_system_prompt(is_individual)},
{"role": "user", "content": prompt}
],
- response_format={"type": "json_object"},
- temperature=0.7 - (attempt * 0.1), # Giảm tính sáng tạo ngẫu nhiên đi một chút mỗi khi fail để tăng khả năng thành công ở vòng tiếp theo
+ response_format={"type": "json_object"}, # Force JSON output mode
+ temperature=0.7 - (attempt * 0.1), # Giảm dần: 0.7 → 0.6 → 0.5
metadata=self._runtime_metadata,
)
-
+
content = response.choices[0].message.content
-
- # Check LLM trả về vì sao bị kẹt lại/Dừng lại (Finish_Reason khác "stop")
+
+ # Kiểm tra tại sao LLM dừng lại
finish_reason = response.choices[0].finish_reason
if finish_reason == 'length':
+ # LLM bị cắt ngang vì hết token → JSON có thể bị thiếu dấu đóng
logger.warning(f"LLM output truncated (attempt {attempt+1}), attempting to fix...")
content = self._fix_truncated_json(content)
-
- # Parse chép vào JSON
+
try:
result = json.loads(content)
-
- # Xác minh tham số được Bot gen thành công chưa
+
+ # Đảm bảo 2 field quan trọng nhất luôn có giá trị
if "bio" not in result or not result["bio"]:
result["bio"] = entity_summary[:200] if entity_summary else f"{entity_type}: {entity_name}"
if "persona" not in result or not result["persona"]:
result["persona"] = entity_summary or f"{entity_name} is a {entity_type}."
-
+
return result
-
+
except json.JSONDecodeError as je:
logger.warning(f"JSON Parsing Failed (attempt {attempt+1}): {str(je)[:80]}")
-
- # Tool sửa lỗi JSON syntax tự chế
+
+ # Thử sửa JSON bằng tool tự chế
result = self._try_fix_json(content, entity_name, entity_type, entity_summary)
if result.get("_fixed"):
del result["_fixed"]
return result
-
+
last_error = je
-
+
except Exception as e:
logger.warning(f"LLM call Failed (attempt {attempt+1}): {str(e)[:80]}")
last_error = e
import time
- time.sleep(1 * (attempt + 1)) # Exponential backoff
-
+ time.sleep(1 * (attempt + 1))
+
+ # Đã hết số lần thử → fallback sang rule-based
logger.warning(f"Generating profile through LLM failed totally after {max_attempts} attempts: {last_error}, switching to basic hard-code rules configs")
return self._generate_profile_rule_based(
entity_name, entity_type, entity_summary, entity_attributes
)
-
+
+ # --------------------------------------------------------------------------
+ # PRIVATE: _fix_truncated_json — Sửa JSON bị cắt gãy do hết token
+ # --------------------------------------------------------------------------
+
def _fix_truncated_json(self, content: str) -> str:
- """Fix Output JSON bị Max_tokens đè cắt gãy"""
+ """
+ Cố gắng vá JSON bị cắt ngang bằng cách đóng các dấu ngoặc còn thiếu.
+
+ Thuật toán:
+ 1. Đếm số '{' và '}' → tính số ngoặc nhọn còn thiếu
+ 2. Đếm số '[' và ']' → tính số ngoặc vuông còn thiếu
+ 3. Nếu ký tự cuối không phải dấu đóng hợp lệ → thêm '"' để đóng string
+ 4. Thêm ']' và '}' còn thiếu vào cuối
+
+ Ví dụ:
+ Input: '{"bio": "Nguyễn Văn A là...", "persona": "Anh ấy sinh ra'
+ Output: '{"bio": "Nguyễn Văn A là...", "persona": "Anh ấy sinh ra"}'
+ """
import re
-
- # Bọc ngoài
+
content = content.strip()
-
- # Điểm kiểm chứng xem dấu ngoặc được đầy đủ hay chưa
+
open_braces = content.count('{') - content.count('}')
open_brackets = content.count('[') - content.count(']')
-
- # Check string xem đủ không
- # Nếu phần tử cuối cùng k phải dấu câu đóng block, tự chèn vào
+
+ # Nếu chuỗi bị cắt giữa chừng trong một string value → đóng string lại
if content and content[-1] not in '",}]':
- # Ngoặc cho đít chuỗi
content += '"'
-
- # Ngoặc block
+
+ # Đóng array và object còn thiếu
content += ']' * open_brackets
content += '}' * open_braces
-
+
return content
-
+
+ # --------------------------------------------------------------------------
+ # PRIVATE: _try_fix_json — Sửa JSON lỗi syntax theo nhiều cấp độ
+ # --------------------------------------------------------------------------
+
def _try_fix_json(self, content: str, entity_name: str, entity_type: str, entity_summary: str = "") -> Dict[str, Any]:
- """Thử Fix nội dung JSON"""
+ """
+ Thử sửa JSON hỏng theo 7 bước từ nhẹ đến nặng:
+
+ 1. _fix_truncated_json() — đóng ngoặc còn thiếu
+ 2. Extract block {} lớn nhất bằng regex
+ 3. Escape newline trong string values
+ 4. json.loads() — lần 1
+ 5. Strip control chars (\\x00-\\x1f) → json.loads() — lần 2
+ 6. Regex rescue: lụm bio và persona riêng lẻ (dù JSON hỏng hoàn toàn)
+ 7. Trả về dict tối thiểu nếu không cứu được gì
+
+ Returns:
+ Dict (có thể rất đơn giản) với "_fixed": True nếu cứu được
+ """
import re
-
- # 1. Bọc json trước
+
+ # Bước 1: Đóng ngoặc
content = self._fix_truncated_json(content)
-
- # 2. Extract block lớn
+
+ # Bước 2: Tìm block JSON lớn nhất trong chuỗi
json_match = re.search(r'\{[\s\S]*\}', content)
if json_match:
json_str = json_match.group()
-
- # 3. Clean lại các chuỗi xuống dòng
- # Regex lôi code ra ngoài
+
+ # Bước 3: Escape newline ẩn trong string values
def fix_string_newlines(match):
s = match.group(0)
- # Escape code line chuyển cho space cho an toàn
s = s.replace('\n', ' ').replace('\r', ' ')
- # Chém các khoảng trống còn dư quá gắt
s = re.sub(r'\s+', ' ', s)
return s
-
- # Khớp lại nội dung
+
json_str = re.sub(r'"[^"\\]*(?:\\.[^"\\]*)*"', fix_string_newlines, json_str)
-
- # 4. Bắt đầu json parse
+
+ # Bước 4: Parse lần 1
try:
result = json.loads(json_str)
result["_fixed"] = True
return result
except json.JSONDecodeError as e:
- # 5. Phá lấu clean xóa nếu còn bị lỗi Control char ẩn (0x00 đến 0x1f ...)
+ # Bước 5: Strip control characters ẩn rồi parse lại
try:
- # Chém control character
json_str = re.sub(r'[\x00-\x1f\x7f-\x9f]', ' ', json_str)
- # Gọt lại khoảng trống dư
json_str = re.sub(r'\s+', ' ', json_str)
result = json.loads(json_str)
result["_fixed"] = True
return result
except:
pass
-
- # 6. Rescue lấy các property còn lại mót ra từ đóng hỗn độn
+
+ # Bước 6: Regex rescue — tìm bio và persona trong đống hỗn độn
bio_match = re.search(r'"bio"\s*:\s*"([^"]*)"', content)
- persona_match = re.search(r'"persona"\s*:\s*"([^"]*)', content) # Bị cắt khúc thì ráng chịu
-
+ persona_match = re.search(r'"persona"\s*:\s*"([^"]*)', content)
+
bio = bio_match.group(1) if bio_match else (entity_summary[:200] if entity_summary else f"{entity_type}: {entity_name}")
persona = persona_match.group(1) if persona_match else (entity_summary or f"{entity_name} is a {entity_type}.")
-
- # Lụm mót được data xịn thì mark là fix thành công
+
if bio_match or persona_match:
logger.info(f"Successfully extracted partial info from corrupted JSON")
return {
@@ -665,22 +848,37 @@ class OasisProfileGenerator:
"persona": persona,
"_fixed": True
}
-
- # 7. Failed sạch, quăng cái khung mặc định ra
+
+ # Bước 7: Không cứu được gì → trả về dict tối thiểu (không có _fixed → caller biết dùng fallback)
logger.warning(f"Failed to fix JSON, returning basic structured data")
return {
"bio": entity_summary[:200] if entity_summary else f"{entity_type}: {entity_name}",
"persona": entity_summary or f"{entity_name} is a {entity_type}."
}
-
+
+ # --------------------------------------------------------------------------
+ # PRIVATE: _get_system_prompt — System prompt cho LLM
+ # --------------------------------------------------------------------------
+
def _get_system_prompt(self, is_individual: bool) -> str:
- """Lấy prompt cho hệ thống"""
-
- # base_prompt = "You are an expert in generating social media user personas. Generate detailed and realistic personas for public opinion simulation to recreate existing real-world conditions to the greatest extent possible. You must return a valid JSON format; all string values must not contain unescaped line breaks. Use Vietnamese."
-
+ """
+ Trả về system prompt chung cho LLM (hiện tại không phân biệt individual/group).
+ Nhấn mạnh: trả về JSON hợp lệ, không có newline thô trong string values.
+ """
base_prompt = "Bạn là chuyên gia tạo hồ sơ người dùng mạng xã hội. Hãy tạo các nhân vật chi tiết và chân thực phục vụ cho việc mô phỏng dư luận, nhằm tái hiện tối đa các tình huống thực tế hiện có. Phải trả về định dạng JSON hợp lệ; tất cả các giá trị chuỗi không được chứa ký tự xuống dòng chưa được xử lý (unescaped). Sử dụng tiếng Việt."
return base_prompt
-
+
+ # --------------------------------------------------------------------------
+ # PRIVATE: _build_individual_persona_prompt / _build_group_persona_prompt
+ # --------------------------------------------------------------------------
+ # Hai hàm này tạo user prompt gửi cho LLM.
+ # Cấu trúc gồm: thông tin entity + context + yêu cầu 8 fields JSON cụ thể.
+ #
+ # Điểm khác biệt chính:
+ # - Individual: persona ~2000 từ với ký ức cá nhân, tuổi thực, gender male/female
+ # - Group: persona ~2000 từ với ký ức tổ chức, age=30 cố định, gender="other"
+ # --------------------------------------------------------------------------
+
def _build_individual_persona_prompt(
self,
entity_name: str,
@@ -689,45 +887,16 @@ class OasisProfileGenerator:
entity_attributes: Dict[str, Any],
context: str
) -> str:
- """Tạo prompt nhân vật chi tiết cho thực thể cá nhân"""
-
+ """
+ Tạo user prompt cho entity CÁ NHÂN.
+
+ Yêu cầu LLM sinh JSON 8 fields:
+ bio, persona, age (int), gender ("male"/"female"), mbti, country, profession, interested_topics
+
+ Context được cắt tối đa 3000 ký tự để tránh vượt context window của LLM.
+ """
attrs_str = json.dumps(entity_attributes, ensure_ascii=False) if entity_attributes else "Không có"
context_str = context[:3000] if context else "Không có ngữ cảnh bổ sung"
-
-# return f"""Generate a detailed social media user persona for the entity, recreating existing real-world conditions to the greatest extent possible.
-
-# Entity Name: {entity_name}
-# Entity Type: {entity_type}
-# Entity Summary: {entity_summary}
-# Entity Attributes: {attrs_str}
-
-# Context Information:
-# {context_str}
-
-# Please generate a JSON containing the following fields:
-
-# 1. bio: Social media biography, 200 characters.
-# 2. persona: Detailed persona description (2000 words of plain text), which must include:
-# - Basic information (age, occupation, educational background, location)
-# - Background (significant experiences, connection to the event, social relationships)
-# - Personality traits (MBTI type, core personality, emotional expression style)
-# - Social media behavior (posting frequency, content preferences, interaction style, linguistic characteristics)
-# - Stance and views (attitude toward the topic, content that might provoke or move them)
-# - Unique features (catchphrases, special experiences, personal hobbies)
-# - Personal memory (a vital part of the persona, describing the individual's connection to the event and their existing actions/reactions)
-# 3. age: Age as a number (must be an integer)
-# 4. gender: Gender, must be in English: "male" or "female"
-# 5. mbti: MBTI type (e.g., INTJ, ENFP, etc.)
-# 6. country: Country (use Vietnamese, e.g., "Việt Nam")
-# 7. profession: Occupation
-# 8. interested_topics: An array of interested topics
-
-# IMPORTANT:
-# - All field values must be strings or numbers; do not use line breaks.
-# - The 'persona' must be a coherent block of text description.
-# - Use Vietnamese (except for the 'gender' field, which must be English male/female).
-# - Content must remain consistent with the entity information.
-# - 'age' must be a valid integer; 'gender' must be "male" or "female"."""
return f"""Tạo hồ sơ người dùng mạng xã hội chi tiết cho thực thể, tái hiện tối đa các tình huống thực tế hiện có.
@@ -740,7 +909,7 @@ Thông tin ngữ cảnh:
{context_str}
Vui lòng tạo JSON bao gồm các trường sau:
-
+
1. bio: Tiểu sử mạng xã hội, 200 ký tự.
2. persona: Mô tả nhân vật chi tiết (văn bản thuần túy khoảng 2000 từ), cần bao gồm:
- Thông tin cơ bản (tuổi, nghề nghiệp, trình độ học vấn, nơi ở)
@@ -756,7 +925,7 @@ Vui lòng tạo JSON bao gồm các trường sau:
6. country: Quốc gia (sử dụng tiếng Việt, ví dụ: "Việt Nam")
7. profession: Nghề nghiệp
8. interested_topics: Mảng các chủ đề quan tâm
-
+
QUAN TRỌNG:
- Tất cả giá trị các trường phải là chuỗi hoặc số, không sử dụng ký tự xuống dòng.
- 'persona' phải là một đoạn mô tả văn bản mạch lạc.
@@ -773,47 +942,17 @@ QUAN TRỌNG:
entity_attributes: Dict[str, Any],
context: str
) -> str:
- """Tạo prompt chi tiết cho tài khoản đại diện tổ chức/nhóm"""
-
+ """
+ Tạo user prompt cho entity TỔ CHỨC/NHÓM.
+
+ Khác biệt so với individual prompt:
+ - age: cố định 30 (tuổi ảo cho tài khoản tổ chức)
+ - gender: cố định "other"
+ - persona nhấn mạnh "ký ức tổ chức" và phong cách phát ngôn chính thức
+ """
attrs_str = json.dumps(entity_attributes, ensure_ascii=False) if entity_attributes else "None"
context_str = context[:3000] if context else "No additional context"
-# return f"""Generate a detailed social media account persona for an organization/group entity, recreating existing real-world conditions to the greatest extent possible.
-
-# Entity Name: {entity_name}
-# Entity Type: {entity_type}
-# Entity Summary: {entity_summary}
-# Entity Attributes: {attrs_str}
-
-# Context Information:
-# {context_str}
-
-# Please generate a JSON containing the following fields:
-
-# 1. bio: Official account biography, 200 characters, professional and appropriate.
-# 2. persona: Detailed account setting description (2000 words of plain text), which must include:
-# - Basic information (formal name, nature of the organization, establishment background, primary functions)
-# - Account positioning (account type, target audience, core functions)
-# - Communication style (linguistic characteristics, common expressions, taboo topics)
-# - Content characteristics (content types, posting frequency, active time periods)
-# - Stance and attitude (official stance on core topics, handling of controversies)
-# - Special notes (persona of the group represented, operational habits)
-# - Organizational memory (a vital part of the persona, describing the organization's connection to the event and its existing actions/reactions)
-# 3. age: Fixed at 30 (virtual age for an organizational account)
-# 4. gender: Fixed as "other" (representing non-individual accounts)
-# 5. mbti: MBTI type used to describe the account's style (e.g., ISTJ for rigorous/conservative)
-# 6. country: Country (use Vietnamese, e.g., "Việt Nam")
-# 7. profession: Description of organizational functions
-# 8. interested_topics: An array of focused fields/areas of interest
-
-# IMPORTANT:
-# - All field values must be strings or numbers; null values are not allowed.
-# - 'persona' must be a coherent block of text description; do not use line breaks.
-# - Use Vietnamese (except for the 'gender' field, which must be the English string "other").
-# - 'age' must be the integer 30; 'gender' must be the string "other".
-# - The account's tone and discourse must strictly align with its institutional identity and positioning.
-# """
-
return f"""Tạo thiết lập tài khoản mạng xã hội chi tiết cho thực thể tổ chức/nhóm, tái hiện tối đa các tình huống thực tế hiện có.
Tên thực thể: {entity_name}
@@ -825,7 +964,7 @@ Thông tin ngữ cảnh:
{context_str}
Vui lòng tạo JSON bao gồm các trường sau:
-
+
1. bio: Tiểu sử tài khoản chính thức, 200 ký tự, chuyên nghiệp và chuẩn mực.
2. persona: Mô tả chi tiết thiết lập tài khoản (văn bản thuần túy khoảng 2000 từ), cần bao gồm:
- Thông tin cơ bản về tổ chức (tên chính thức, tính chất tổ chức, bối cảnh thành lập, chức năng chính)
@@ -841,7 +980,7 @@ Vui lòng tạo JSON bao gồm các trường sau:
6. country: Quốc gia (sử dụng tiếng Việt, ví dụ: "Việt Nam")
7. profession: Mô tả chức năng của tổ chức
8. interested_topics: Mảng các lĩnh vực quan tâm
-
+
QUAN TRỌNG:
- Tất cả giá trị các trường phải là chuỗi hoặc số, không cho phép giá trị null.
- 'persona' phải là một đoạn mô tả văn bản mạch lạc, không sử dụng ký tự xuống dòng.
@@ -849,7 +988,14 @@ QUAN TRỌNG:
- 'age' phải là số nguyên 30, 'gender' phải là chuỗi "other".
- Phát ngôn và giọng điệu của tài khoản phải phù hợp tuyệt đối với định vị danh tính và đặc thù của tổ chức.
"""
-
+
+ # --------------------------------------------------------------------------
+ # PRIVATE: _generate_profile_rule_based — Fallback không dùng LLM
+ # --------------------------------------------------------------------------
+ # Khi LLM fail hoàn toàn, hàm này trả về profile "cứng" dựa trên entity_type.
+ # Chất lượng thấp hơn LLM nhiều nhưng đảm bảo pipeline không bị block.
+ # --------------------------------------------------------------------------
+
def _generate_profile_rule_based(
self,
entity_name: str,
@@ -857,11 +1003,18 @@ QUAN TRỌNG:
entity_summary: str,
entity_attributes: Dict[str, Any]
) -> Dict[str, Any]:
- """Sử dụng rule để tạo Profile cơ bản khi dự phòng"""
-
- # Phân nhánh theo loại thực thể để tạo Profile thủ công
+ """
+ Sinh profile bằng template cứng theo loại entity — không gọi LLM.
+
+ Ưu điểm: nhanh, không tốn token, không bao giờ fail
+ Nhược điểm: generic, thiếu cá tính, không phản ánh context thực của entity
+
+ Các loại được xử lý riêng: student/alumni, publicfigure/expert/faculty,
+ mediaoutlet, university/governmentagency/ngo/organization.
+ Tất cả loại khác → profile mặc định chung.
+ """
entity_type_lower = entity_type.lower()
-
+
if entity_type_lower in ["student", "alumni"]:
return {
"bio": f"{entity_type} with interests in academics and social issues.",
@@ -873,45 +1026,45 @@ QUAN TRỌNG:
"profession": "Student",
"interested_topics": ["Education", "Social Issues", "Technology"],
}
-
+
elif entity_type_lower in ["publicfigure", "expert", "faculty"]:
return {
"bio": f"Expert and thought leader in their field.",
"persona": f"{entity_name} is a recognized {entity_type.lower()} who shares insights and opinions on important matters. They are known for their expertise and influence in public discourse.",
"age": random.randint(35, 60),
"gender": random.choice(["male", "female"]),
- "mbti": random.choice(["ENTJ", "INTJ", "ENTP", "INTP"]),
+ "mbti": random.choice(["ENTJ", "INTJ", "ENTP", "INTP"]), # Nhóm MBTI thiên về tư duy/lãnh đạo
"country": random.choice(self.COUNTRIES),
"profession": entity_attributes.get("occupation", "Expert"),
"interested_topics": ["Politics", "Economics", "Culture & Society"],
}
-
+
elif entity_type_lower in ["mediaoutlet", "socialmediaplatform"]:
return {
"bio": f"Official account for {entity_name}. News and updates.",
"persona": f"{entity_name} is a media entity that reports news and facilitates public discourse. The account shares timely updates and engages with the audience on current events.",
- "age": 30, # Tuổi ảo của cơ quan/tổ chức
- "gender": "other", # Cơ quan dùng "other"
- "mbti": "ISTJ", # Phong cách tổ chức: nghiêm túc bảo thủ
+ "age": 30, # Tuổi ảo cố định cho tổ chức
+ "gender": "other", # Không phải cá nhân
+ "mbti": "ISTJ", # Nghiêm túc, thận trọng, bảo thủ — phù hợp media chính thống
"country": "Việt Nam",
"profession": "Media",
"interested_topics": ["General News", "Current Events", "Public Affairs"],
}
-
+
elif entity_type_lower in ["university", "governmentagency", "ngo", "organization"]:
return {
"bio": f"Official account of {entity_name}.",
"persona": f"{entity_name} is an institutional entity that communicates official positions, announcements, and engages with stakeholders on relevant matters.",
- "age": 30, # Tuổi ảo của cơ quan/tổ chức
- "gender": "other", # Cơ quan dùng "other"
- "mbti": "ISTJ", # Phong cách tổ chức: nghiêm túc bảo thủ
+ "age": 30,
+ "gender": "other",
+ "mbti": "ISTJ",
"country": "Việt Nam",
"profession": entity_type,
"interested_topics": ["Public Policy", "Community", "Official Announcements"],
}
-
+
else:
- # Profile mặc định (Fallback default)
+ # Fallback hoàn toàn chung — dùng cho mọi loại không match trên
return {
"bio": entity_summary[:150] if entity_summary else f"{entity_type}: {entity_name}",
"persona": entity_summary or f"{entity_name} is a {entity_type.lower()} participating in social discussions.",
@@ -922,11 +1075,30 @@ QUAN TRỌNG:
"profession": entity_type,
"interested_topics": ["General", "Social Issues"],
}
-
+
def set_graph_id(self, graph_id: str):
- """Lưu lại Graph ID để dùng cho việc tra cứu Zep"""
+ """Cập nhật graph_id sau khi khởi tạo (dùng khi graph_id chưa biết lúc __init__)."""
self.graph_id = graph_id
-
+
+ # --------------------------------------------------------------------------
+ # PUBLIC: generate_profiles_from_entities — Sinh hàng loạt song song
+ # --------------------------------------------------------------------------
+ # Đây là hàm được gọi bởi simulation_manager.prepare_simulation() (Giai đoạn 2).
+ # Dùng ThreadPoolExecutor để chạy parallel_count threads đồng thời.
+ #
+ # Tại sao cần parallel?
+ # Mỗi profile cần 1-3 LLM API call + 1 Zep search call → ~5-15 giây/profile.
+ # Với 50 profiles: sequential = 250-750s, parallel (3 threads) = ~100-250s.
+ #
+ # Thứ tự profile:
+ # ThreadPoolExecutor.as_completed() không đảm bảo thứ tự → dùng mảng profiles[idx]
+ # được cấp phát trước để đảm bảo user_id đúng với entity ban đầu.
+ #
+ # Realtime output:
+ # Mỗi khi 1 thread hoàn thành, gọi save_profiles_realtime() để ghi file ngay.
+ # Frontend có thể đọc file này để hiển thị tiến độ thực tế.
+ # --------------------------------------------------------------------------
+
def generate_profiles_from_entities(
self,
entities: List[EntityNode],
@@ -941,28 +1113,34 @@ QUAN TRỌNG:
project_id: Optional[str] = None,
) -> List[OasisAgentProfile]:
"""
- Khởi tạo hàng loạt các Agent Profile từ các thực thể (Hỗ trợ Gen đa luồng song song)
-
+ Sinh hàng loạt profiles từ danh sách entities — chạy song song.
+
+ Đảm bảo:
+ - profiles[i] luôn tương ứng với entities[i] (thứ tự không bị xáo trộn)
+ - Nếu 1 entity fail → vẫn tạo fallback profile, không block cả batch
+ - File được ghi realtime sau mỗi profile hoàn thành
+
Args:
- entities: Danh sách thực thể
- use_llm: Có sử dụng LLM để tạo tính cách chi tiết hay không
- progress_callback: Hàm CallBack báo tiến độ (current, total, message)
- graph_id: Đưa Graph ID vào để Zep retrieval thêm nhiều ngữ cảnh phong phú
- parallel_count: Số luồng song song, mặc định 5
- realtime_output_path: Đường dẫn lưu file realtime (Gen ra đứa nào auto save đứa đó luôn)
- output_platform: Format lưu trữ output ("reddit" hoạc "twitter")
- metadata_platform: Nền tảng dùng cho metadata cost
-
+ entities: Danh sách EntityNode từ ZepEntityReader
+ use_llm: True = gọi LLM; False = rule-based
+ progress_callback: callback(current, total, message) để báo tiến độ
+ graph_id: Zep graph ID cho vector search
+ parallel_count: Số threads chạy đồng thời (mặc định 5)
+ realtime_output_path: Đường dẫn file để ghi realtime (None = không ghi)
+ output_platform: "reddit" (JSON) hoặc "twitter" (CSV)
+ metadata_platform: Tên platform cho log cost API
+ simulation_id, project_id: Metadata cho cost tracking
+
Returns:
- Danh sách Profile Agent
+ List[OasisAgentProfile] với len == len(entities), không có None
"""
import concurrent.futures
from threading import Lock
-
- # Lưu Graph ID lại cho Zep xử lý search
+
if graph_id:
self.graph_id = graph_id
+ # Cập nhật metadata cho cost tracking
self._runtime_metadata = {
"component": "oasis_profile_generator",
"phase": "generate_profiles",
@@ -970,32 +1148,29 @@ QUAN TRỌNG:
"project_id": project_id,
"platform": metadata_platform,
}
-
+
total = len(entities)
- profiles = [None] * total # Cấp trước 1 mảng để giữ đúng thứ tự Index
- completed_count = [0] # Phải dùng List để closure của các Sub Thread update được
- lock = Lock()
-
- # Hàm con hỗ trợ việc ghi realtime file trong Thread
+ profiles = [None] * total # Pre-allocate để giữ đúng thứ tự index
+ completed_count = [0] # List thay vì int để closure có thể mutate
+ lock = Lock() # Bảo vệ completed_count và file write khỏi race condition
+
def save_profiles_realtime():
- """Lưu file json ngay lập tức khi profile được tạo mới thành công"""
+ """Ghi file ngay lập tức khi có profile mới (thread-safe qua lock)."""
if not realtime_output_path:
return
-
+
with lock:
- # Lọc ra những profile đã làm xong
existing_profiles = [p for p in profiles if p is not None]
if not existing_profiles:
return
-
+
try:
if output_platform == "reddit":
- # Cấu trúc dành cho định dạng Reddit
profiles_data = [p.to_reddit_format() for p in existing_profiles]
with open(realtime_output_path, 'w', encoding='utf-8') as f:
json.dump(profiles_data, f, ensure_ascii=False, indent=2)
else:
- # Cấu trúc dành cho định dạng Twitter (CSV)
+ # Twitter: CSV format
import csv
profiles_data = [p.to_twitter_format() for p in existing_profiles]
if profiles_data:
@@ -1006,26 +1181,28 @@ QUAN TRỌNG:
writer.writerows(profiles_data)
except Exception as e:
logger.warning(f"Failed to save profile in realtime: {e}")
-
+
def generate_single_profile(idx: int, entity: EntityNode) -> tuple:
- """Hàm Worker gen từng profile riêng lẻ"""
+ """
+ Worker function chạy trong thread riêng cho mỗi entity.
+
+ Returns:
+ (idx, profile, error_message_or_None)
+ """
entity_type = entity.get_entity_type() or "Entity"
-
+
try:
profile = self.generate_profile_from_entity(
entity=entity,
user_id=idx,
use_llm=use_llm
)
-
- # Print output để nhìn trực tiếp Log terminal
self._print_generated_profile(entity.name, entity_type, profile)
-
return idx, profile, None
-
+
except Exception as e:
logger.error(f"Failed to generate profile for entity {entity.name}: {str(e)}")
- # Rơi vào tạo Profile dự phòng (Fallback)
+ # Tạo profile tối thiểu để pipeline không bị block
fallback_profile = OasisAgentProfile(
user_id=idx,
user_name=self._generate_username(entity.name),
@@ -1036,52 +1213,53 @@ QUAN TRỌNG:
source_entity_type=entity_type,
)
return idx, fallback_profile, str(e)
-
+
logger.info(f"Start parallel profile generation for {total} entities (Concurrency: {parallel_count})...")
print(f"\n{'='*60}")
print(f"Starting Agent Profile Generation - Total {total} entities, concurrency: {parallel_count}")
print(f"{'='*60}\n")
-
- # Chạy đa luồng thread pool
+
+ # Chạy ThreadPoolExecutor với parallel_count workers đồng thời
with concurrent.futures.ThreadPoolExecutor(max_workers=parallel_count) as executor:
- # Giao Task
+ # Submit tất cả tasks cùng lúc
future_to_entity = {
executor.submit(generate_single_profile, idx, entity): (idx, entity)
for idx, entity in enumerate(entities)
}
-
- # Thu gom kết quả
+
+ # Thu kết quả theo thứ tự hoàn thành (không phải thứ tự submit)
for future in concurrent.futures.as_completed(future_to_entity):
idx, entity = future_to_entity[future]
entity_type = entity.get_entity_type() or "Entity"
-
+
try:
result_idx, profile, error = future.result()
- profiles[result_idx] = profile
-
+ profiles[result_idx] = profile # Đặt vào đúng index, không theo thứ tự hoàn thành
+
with lock:
completed_count[0] += 1
current = completed_count[0]
-
- # Ghi file Realtime
+
+ # Ghi file ngay sau khi có thêm 1 profile
save_profiles_realtime()
-
+
if progress_callback:
progress_callback(
- current,
- total,
+ current,
+ total,
f"Completed {current}/{total}: {entity.name} ({entity_type})"
)
-
+
if error:
logger.warning(f"[{current}/{total}] Entity {entity.name} applied fallback profile due to error: {error}")
else:
logger.info(f"[{current}/{total}] Automatically generated profile for: {entity.name} ({entity_type})")
-
+
except Exception as e:
logger.error(f"Error handling profile for entity {entity.name}: {str(e)}")
with lock:
completed_count[0] += 1
+ # Emergency fallback — đảm bảo slot không bị None
profiles[idx] = OasisAgentProfile(
user_id=idx,
user_name=self._generate_username(entity.name),
@@ -1091,22 +1269,28 @@ QUAN TRỌNG:
source_entity_uuid=entity.uuid,
source_entity_type=entity_type,
)
- # Ghi file Realtime file (Dù là profile xài fallback)
save_profiles_realtime()
-
+
print(f"\n{'='*60}")
print(f"Profile generation complete! Successfully created {len([p for p in profiles if p])} Agents")
print(f"{'='*60}\n")
-
+
return profiles
-
+
+ # --------------------------------------------------------------------------
+ # PRIVATE: _print_generated_profile — In log profile ra terminal
+ # --------------------------------------------------------------------------
+
def _print_generated_profile(self, entity_name: str, entity_type: str, profile: OasisAgentProfile):
- """Xuất thông tin Profile vưa gen ra Terminal để review dễ dàng (Kéo dài không bị gãy log)"""
+ """
+ In thông tin profile vừa tạo ra terminal để review.
+
+ Dùng print() thay vì logger để tránh logger truncate persona dài.
+ Format rõ ràng với separator và các section riêng biệt.
+ """
separator = "-" * 70
-
- # Xây cấu trúc Log
topics_str = ', '.join(profile.interested_topics) if profile.interested_topics else 'Không có'
-
+
output_lines = [
f"\n{separator}",
f"[Generated] {entity_name} ({entity_type})",
@@ -1125,12 +1309,13 @@ QUAN TRỌNG:
f"Chủ đề quan tâm: {topics_str}",
separator
]
-
- output = "\n".join(output_lines)
-
- # Chỉ in ra Console bằng lệnh print (Logger sẽ làm rối và có thể bị truncate)
- print(output)
-
+
+ print("\n".join(output_lines))
+
+ # --------------------------------------------------------------------------
+ # PUBLIC: save_profiles — Dispatcher ghi file theo nền tảng
+ # --------------------------------------------------------------------------
+
def save_profiles(
self,
profiles: List[OasisAgentProfile],
@@ -1138,158 +1323,165 @@ QUAN TRỌNG:
platform: str = "reddit"
):
"""
- Ghi file Profile xuống thư mục (Cấu trúc file tuỳ thuộc vào nền tảng)
-
- Định dạng mặc định của Framework OASIS yêu cầu:
- - Twitter: Định dạng file CSV
- - Reddit: Định dạng file JSON
-
+ Ghi toàn bộ profiles ra file theo định dạng của từng nền tảng.
+
+ Dispatcher: gọi đúng hàm save dựa vào platform:
+ - "twitter" → _save_twitter_csv() (OASIS yêu cầu CSV)
+ - "reddit" (mặc định) → _save_reddit_json() (OASIS yêu cầu JSON)
+
Args:
- profiles: Danh sách Profile
- file_path: Đường dẫn lưu file
- platform: Tên nền tảng ("reddit" hoặc "twitter")
+ profiles: Danh sách profiles cần lưu
+ file_path: Đường dẫn file output
+ platform: "reddit" hoặc "twitter"
"""
if platform == "twitter":
self._save_twitter_csv(profiles, file_path)
else:
self._save_reddit_json(profiles, file_path)
-
+
+ # --------------------------------------------------------------------------
+ # PRIVATE: _save_twitter_csv — Lưu profiles theo chuẩn CSV của OASIS Twitter
+ # --------------------------------------------------------------------------
+
def _save_twitter_csv(self, profiles: List[OasisAgentProfile], file_path: str):
"""
- Lưu Profile hệ Twitter ở định dạng CSV (Bám vào yêu cầu kỹ thuật do OASIS ban hành)
-
- Các trường bắt buộc để tương thích OASIS Twitter File CSV:
- - user_id: Mã định danh ID (Từ 0 theo Index mảng)
- - name: Tên thật của Agent đó
- - username: Tên Alias/Tài khoản xài trong hệ thống
- - user_char: Bản nháp Setting cụ thể truyền vào System Prompt LLM, định hình mọi ý nghĩ/phát ngôn
- - description: Bản Bio gắn ngoài hiển thị cho các User khác thấy (Ngắn gọn)
-
- Sự khác biệt user_char và description:
- - user_char: Data nội bộ chỉ Gen AI thấy (Giống prompt điều khiển não)
- - description: Public Info đưa lên trang cá nhân
+ Ghi profiles ra file CSV theo đúng chuẩn OASIS Twitter framework.
+
+ Cấu trúc CSV (5 cột cố định):
+ ┌──────────┬──────────┬──────────┬──────────────────────┬─────────────────┐
+ │ user_id │ name │ username │ user_char │ description │
+ ├──────────┼──────────┼──────────┼──────────────────────┼─────────────────┤
+ │ 0 │ Tên thật │ alias_xx │ bio + " " + persona │ bio (public) │
+ └──────────┴──────────┴──────────┴──────────────────────┴─────────────────┘
+
+ Lưu ý quan trọng:
+ - user_char = bio + persona → đây là system prompt bí mật của LLM agent
+ - description = bio → thông tin hiển thị công khai
+ - Tất cả newline trong string phải được strip (CSV không chịu được)
"""
import csv
-
- # Check đuôi file có nhầm thành json không
+
+ # Tự sửa đuôi file nếu nhầm
if not file_path.endswith('.csv'):
file_path = file_path.replace('.json', '.csv')
-
+
with open(file_path, 'w', newline='', encoding='utf-8') as f:
writer = csv.writer(f)
-
- # Khởi tạo Header theo chuẩn file OASIS
headers = ['user_id', 'name', 'username', 'user_char', 'description']
writer.writerow(headers)
-
- # Xuất từng dòng Dữ liệu Profile
+
for idx, profile in enumerate(profiles):
- # user_char: Nhân cách tổng (bio + persona) - Thả cho Prompt System LLM
+ # Ghép bio + persona thành user_char (system prompt của agent)
user_char = profile.bio
if profile.persona and profile.persona != profile.bio:
user_char = f"{profile.bio} {profile.persona}"
- # Làm sạch dấu newline để nhét vào dòng CSV
+ # Strip newline vì CSV không xử lý được multiline trong 1 cell
user_char = user_char.replace('\n', ' ').replace('\r', ' ')
-
- # description: Thông tin Bio hiển thị công khai mạng xã hội
+
description = profile.bio.replace('\n', ' ').replace('\r', ' ')
-
+
row = [
- idx, # user_id: ID bắt đầu từ 0
- profile.name, # name: Tên thực
- profile.user_name, # username: Tên định danh
- user_char, # user_char: Mô tả ẩn của Bot
- description # description: Bảng mô tả Công khai
+ idx,
+ profile.name,
+ profile.user_name,
+ user_char,
+ description
]
writer.writerow(row)
-
+
logger.info(f"Saved {len(profiles)} Twitter Profiles to {file_path} (OASIS CSV Format)")
-
+
+ # --------------------------------------------------------------------------
+ # PRIVATE: _normalize_gender — Chuẩn hoá gender về enum OASIS chấp nhận
+ # --------------------------------------------------------------------------
+
def _normalize_gender(self, gender: Optional[str]) -> str:
"""
- Biên dịch, chuẩn hóa cột Gender về đúng dạng mà OASIS engine chấp nhận
-
- OASIS quy định buộc xài enum: male, female, other
+ Chuyển đổi mọi dạng gender string về 3 giá trị OASIS chấp nhận: "male", "female", "other".
+
+ Xử lý:
+ - Tiếng Việt: "nam" → "male", "nữ" → "female", "tổ chức" → "other"
+ - Tiếng Trung: "男" → "male", "女" → "female", "机构" → "other"
+ - Không có giá trị → "other" (an toàn nhất)
+ - Không match bất kỳ → "other" (fallback)
"""
if not gender:
return "other"
-
+
gender_lower = gender.lower().strip()
-
- # Mapping các Keyword
+
gender_map = {
- "男": "male",
- "女": "female",
- "机构": "other",
- "其他": "other",
+ # Tiếng Việt
"nam": "male",
"nữ": "female",
"tổ chức": "other",
- # Giữ nguyên Tiếng Anh Default
+ "khác": "other",
+ # Tiếng Anh
"male": "male",
"female": "female",
"other": "other",
}
-
+
return gender_map.get(gender_lower, "other")
-
+
+ # --------------------------------------------------------------------------
+ # PRIVATE: _save_reddit_json — Lưu profiles theo chuẩn JSON của OASIS Reddit
+ # --------------------------------------------------------------------------
+
def _save_reddit_json(self, profiles: List[OasisAgentProfile], file_path: str):
"""
- Lưu Profile hệ Reddit bằng JSON (Bám vào yêu cầu kỹ thuật do OASIS ban hành)
-
- Format cấu trúc dựa tương đồng với hàm to_reddit_format().
- Luôn luôn phải có thuộc tính user_id, KEY QUAN TRỌNG ĐỂ HỖ TRỢ HÀM agent_graph.get_agent() MAP CÁC PROFILE !!!
-
- Các field bắt buộc:
- - user_id: User ID dạng Int
- - username: ID Account
- - name: Tên hiển thị
- - bio: Thông tin hiển thị Bio cá nhân
- - persona: Prompt Settings điều khiển Bot nội bộ
- - age: Tuổi (Int)
- - gender: "male", "female", hoặc "other"
- - mbti: Kiểu loại nhóm MBTI
- - country: Quốc gia Country
+ Ghi profiles ra file JSON theo đúng chuẩn OASIS Reddit framework.
+
+ Fields bắt buộc (OASIS sẽ crash nếu thiếu):
+ - user_id: int — dùng bởi agent_graph.get_agent() để map agent
+ - username: str — tên tài khoản (không có khoảng trắng)
+ - name: str — tên hiển thị
+ - bio: str — thông tin công khai
+ - persona: str — system prompt bí mật của agent
+ - karma: int — ảnh hưởng đến visibility của post/comment
+ - age, gender, mbti, country: — dùng trong logic agent nếu OASIS cần
+
+ Lưu ý: gender được normalize qua _normalize_gender() trước khi ghi.
"""
data = []
for idx, profile in enumerate(profiles):
- # Parse Format chung với hàm class to_reddit_format()
item = {
- "user_id": profile.user_id if profile.user_id is not None else idx, # Quan trọng: Bắt buộc kèm "user_id"
+ "user_id": profile.user_id if profile.user_id is not None else idx,
"username": profile.user_name,
"name": profile.name,
- "bio": profile.bio[:150] if profile.bio else f"{profile.name}",
+ "bio": profile.bio[:150] if profile.bio else f"{profile.name}", # Cap ở 150 ký tự
"persona": profile.persona or f"{profile.name} is a participant in social discussions.",
"karma": profile.karma if profile.karma else 1000,
"created_at": profile.created_at,
- # Fix bù tham số ảo cho các properties bị trống
+ # Fallback values cho các field tùy chọn để tránh null
"age": profile.age if profile.age else 30,
"gender": self._normalize_gender(profile.gender),
"mbti": profile.mbti if profile.mbti else "ISTJ",
"country": profile.country if profile.country else "Việt Nam",
}
-
- # Cột Tuỳ chọn
+
if profile.profession:
item["profession"] = profile.profession
if profile.interested_topics:
item["interested_topics"] = profile.interested_topics
-
+
data.append(item)
-
+
with open(file_path, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
-
+
logger.info(f"Saved {len(profiles)} Reddit Profiles to {file_path} (JSON Config File - with user_id mapped)")
-
- # Giữ lại Function name cũ để hệ thống vẫn tương thích backward.
+
+ # --------------------------------------------------------------------------
+ # DEPRECATED: save_profiles_to_json — Tên cũ, giữ để tương thích ngược
+ # --------------------------------------------------------------------------
+
def save_profiles_to_json(
self,
profiles: List[OasisAgentProfile],
file_path: str,
platform: str = "reddit"
):
- """[Deprecated - Hết hạn dùng] KHUYÊN DÙNG LỆNH save_profiles() THAY VÌ PHƯƠNG THỨC NÀY"""
+ """[Deprecated] Dùng save_profiles() thay thế. Giữ lại để không break code cũ."""
logger.warning("save_profiles_to_json is Deprecated. Use save_profiles method instead!")
self.save_profiles(profiles, file_path, platform)
-
diff --git a/backend/app/services/report_agent.py b/backend/app/services/report_agent.py
index e8074195..c6d0fd6b 100644
--- a/backend/app/services/report_agent.py
+++ b/backend/app/services/report_agent.py
@@ -984,7 +984,7 @@ Nhiệm vụ của bạn là:
3. [Tính nhất quán về Ngôn ngữ - Nội dung trích dẫn phải được dịch sang ngôn ngữ báo cáo]
- Nội dung trả về từ các công cụ có thể chứa tiếng Anh hoặc hỗn hợp tiếng Việt và tiếng Anh.
- - Nếu yêu cầu mô phỏng và tài liệu gốc bằng tiếng Việt, báo cáo phải được viết hoàn toàn bằng tiếng Việt
+ - **Báo cáo phải được viết hoàn toàn bằng tiếng Việt.**
- Khi bạn trích dẫn nội dung tiếng Anh hoặc hỗn hợp từ công cụ, bạn phải dịch sang tiếng Việt lưu loát trước khi đưa vào báo cáo
- Giữ nguyên ý nghĩa gốc khi dịch và đảm bảo cách diễn đạt tự nhiên
- Quy tắc này áp dụng cho cả văn bản chính và nội dung trong khối trích dẫn (định dạng >)
diff --git a/backend/app/services/simulation_config_generator.py b/backend/app/services/simulation_config_generator.py
index 7dc9d92d..27bb04d5 100644
--- a/backend/app/services/simulation_config_generator.py
+++ b/backend/app/services/simulation_config_generator.py
@@ -615,7 +615,7 @@ Hãy tạo JSON cấu hình thời gian.
Ví dụ Format như sau:
{{
- "total_simulation_hours": 72,
+ "total_simulation_hours": 72,
"minutes_per_round": 60,
"agents_per_hour_min": 5,
"agents_per_hour_max": 50,
diff --git a/backend/app/services/simulation_manager.py b/backend/app/services/simulation_manager.py
index 5cd07bf4..c643741c 100644
--- a/backend/app/services/simulation_manager.py
+++ b/backend/app/services/simulation_manager.py
@@ -21,16 +21,32 @@ from .simulation_config_generator import SimulationConfigGenerator, SimulationPa
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" # Đã khởi tạo
- PREPARING = "preparing" # Đang chuẩn bị (chuẩn bị dữ liệu/profile)
- READY = "ready" # Đã sẵn sàng chạy
- RUNNING = "running" # Đang xử lý giả lập
- PAUSED = "paused" # Tạm dừng
- STOPPED = "stopped" # Hệ thống mô phỏng bị người dùng chủ động dừng lại
- COMPLETED = "completed" # Quá trình mô phỏng kết thúc tự nhiên một cách thành công
- FAILED = "failed" # Bị lỗi hệ thống gián đoạn
+ 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):
@@ -39,50 +55,74 @@ class PlatformType(str, Enum):
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: `//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"""
- simulation_id: str
- project_id: str
- graph_id: str
-
- # Cờ trạng thái bật/tắt nền tảng chạy
+
+ # --- Đị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
-
- # Current status
+
+ # --- 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
-
- # Dữ liệu thu thập / thống kê của Preparing Phase
- entities_count: int = 0
- profiles_count: int = 0
- entity_types: List[str] = field(default_factory=list)
-
- # Thông tin các nội dung cấu hình mà LLM đã tự động tạo
- config_generated: bool = False
- config_reasoning: str = ""
-
- # Dữ liệu cập nhật theo thời gian thực (Runtime Phase)
- current_round: int = 0
- twitter_status: str = "not_started"
- reddit_status: str = "not_started"
-
- # Nhãn Timestamp lịch sử
+
+ # --- 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ịch sử thông báo Lỗi (nếu có để render trả về frontend)
+
+ # --- 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]:
- """Tạo thành Dictionary đầy đủ nhất (Dùng cho việc lưu cấu hình local cho hệ thống bên trong đọc)"""
+ """
+ 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,
+ "status": self.status.value, # Enum → string (ví dụ: "preparing")
"entities_count": self.entities_count,
"profiles_count": self.profiles_count,
"entity_types": self.entity_types,
@@ -95,9 +135,14 @@ class SimulationState:
"updated_at": self.updated_at,
"error": self.error,
}
-
+
def to_simple_dict(self) -> Dict[str, Any]:
- """Tạo thành Dictionary bao hàm các thông số vắn tắt hơn (Dùng cho API response trả về Client - Frontend)"""
+ """
+ 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,
@@ -111,69 +156,122 @@ class SimulationState:
}
+# ==============================================================================
+# 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.
"""
-
- # Nơi chứa thư mục chứa Dữ liệu mô phỏng Local
+
+ # Đườ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__),
+ os.path.dirname(__file__),
'../../uploads/simulations'
)
-
+
def __init__(self):
- # Đảm bảo môi trường file data đã được set up
+ # 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)
-
- # Biến dictionary ở mức Application theo dõi trạng thái simulation qua Cache RAM.
+
+ # 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:
- """Lấy trả về các đường dẫn thư mục gốc tương ứng với Simulation ID"""
+ """
+ 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//
+ ├── 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)
+ 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):
- """Bật tính năng lưu state định dạng JSON ra ổ cứng"""
+ """
+ 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]:
- """Load ngược lại data của tiến trình Mô Phỏng thông qua tệp cấu hình JSON"""
+ """
+ Đọ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
-
+ 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")),
+ 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", []),
@@ -186,10 +284,15 @@ class SimulationManager:
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,
@@ -198,20 +301,27 @@ class SimulationManager:
enable_reddit: bool = True,
) -> SimulationState:
"""
- Khởi tạo môi trường ảo / mới cho Mô Phỏng
-
+ 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
- enable_twitter: Công tắc (Bật/Tắt) luồng giả lập Twitter
- enable_reddit: Công tắc (Bật/Tắt) luồng giả lập Reddit
-
+ 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:
- Đối tượng Class SimulationState
+ 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,
@@ -220,12 +330,17 @@ class SimulationManager:
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,
@@ -238,104 +353,151 @@ class SimulationManager:
) -> SimulationState:
"""
Giai đoạn chuẩn bị dữ liệu tạo giả lập mô phỏng (Tiến trình Automation 100%)
-
- Các bước diễn ra:
- 1. Gọi lấy các cụm Entity (thực thể) và bộ lọt (filter) từ Zep Graph API
- 2. Tự động khởi tạo hàng loạt Agent Profile chạy OASIS tương ứng với Entity (Hỗ trợ gọi AI LLM để làm mượt văn bản / tăng tốc chạy song song)
- 3. Hỏi và bắt bot LLM suy luận ra hệ tham số setting thông minh nhất (thời gian mô phỏng rò rỉ, hệ số tần suất nói chuyện hoạt động ...)
- 4. In ra các file cấu hình và JSON của profile để hệ thống dễ đọc
- 5. Copy nguyên các Scripts chuẩn được cấu hình sẵn (preset) ném vào thư mục để chạy
-
+
+ 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: Mã ID của chu trình giả lập
- simulation_requirement: Chuỗi text từ người dùng yêu cầu mô phỏng gì (gửi cho config sinh cấu hình)
- document_text: Nội dung file Raw nguyên thủy (Cho LLM đánh giá context bối cảnh ban đầu)
- defined_entity_types: Dánh sách các Entity Model có sẵn do Zep định nghĩa (Option)
- use_llm_for_profiles: Toggle tính năng sử dụng mô hình LLM để buff thêm chi tiết cài đặt con Bot
- progress_callback: Hàm callback update log progress (chuyển về màn hình Frontend view) format (stage, progress, message)
- parallel_profile_count: Giới hạn concurrent threading chạy LLM gọi profile (Default là 3 luồng cùng lúc để làm nhanh hơn)
-
+ 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:
- Class cài đặt Data - SimulationState
+ 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 lấy Node Data Entity và Sàng lọc ==========
+
+ # ==========================================================================
+ # 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,
+ "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
-
- # ========== Giai đoạn 2: Bắt đầu sinh Agent Profiles cho OASIS ==========
+ 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,
+ "generating_profiles", 0,
"Ready for AI Generation process...",
current=0,
total=total_entities
)
-
- # Gửi mã graph_id để bộ Profile có thể fetch thêm tài liệu nếu model cần lục vấn sâu
+
+ # 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),
+ "generating_profiles",
+ int(current / total * 100), # Tính phần trăm hoàn thành
msg,
current=current,
total=total,
item_name=msg
)
-
- # Khai báo đường dẫn tạm để AI lưu Real-time kết quả (Đặt ưu tiên Platform Reddit JSON làm chuẩn)
+
+ # --- 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"
- # Nền tảng dùng cho metadata cost.
+ # --- 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:
@@ -343,75 +505,90 @@ class SimulationManager:
elif state.enable_reddit:
runtime_platform = "reddit"
else:
- runtime_platform = None
-
+ 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 Zep Search Index
- parallel_count=parallel_profile_count, # Số dòng luồng Async
- realtime_output_path=realtime_output_path, # Lưu log thời gian thực
- output_platform=realtime_platform, # Đuôi file xuất
- metadata_platform=runtime_platform,
+ 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)
-
- # Backup lại kết quả Profile (Twitter xuất ra text CSV, Reddit thì bắt buộc JSON cho cấu trúc OASIS)
- # Reddit đã được render đồng thời ở block trên nhưng đây là re-save toàn bộ
+
+ # --- 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,
+ "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:
- # Riêng Twitter với code Script base OAsis của họ yêu cầu CSV format
+ # 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,
+ "generating_profiles", 100,
f"Done, created {len(profiles)} Profiles",
current=len(profiles),
total=len(profiles)
)
-
- # ========== Giai đoạn 3: Uỷ thác cho LLM phân tích và xuất tham số mô phỏng ==========
+
+ # ==========================================================================
+ # 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,
+ "generating_config", 0,
"Analyzing input requirements...",
current=0,
total=3
)
-
+
config_generator = SimulationConfigGenerator()
-
+
if progress_callback:
progress_callback(
- "generating_config", 30,
+ "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,
@@ -422,106 +599,175 @@ class SimulationManager:
enable_twitter=state.enable_twitter,
enable_reddit=state.enable_reddit
)
-
+
if progress_callback:
progress_callback(
- "generating_config", 70,
+ "generating_config", 70,
"Saving Config parameters...",
current=2,
total=3
)
-
- # Lưu file cứng simulation_config.json
+
+ # 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())
-
+ 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,
+ "generating_config", 100,
"Configuration Generation complete",
current=3,
total=3
)
-
- # Lưu ý kiến trúc: Các scripts thao tác thực thi vẫn để gốc ở `backend/scripts/`, SẼ KHÔNG CẦN chép đè sang folder Project
- # Tại thời gian Khởi chạy, `simulation_runner` sẽ nạp base chạy thẳng từ folder `scripts/` đó.
-
- # Cập nhật status
+
+ # ==========================================================================
+ # 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 để 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
-
+
+ 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]:
- """Đọc và lấy State hiện tại của Simulator"""
+ """
+ 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ê toàn bộ danh sách các Mô Phỏng (Simulations) đã khởi tạo"""
+ """
+ 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):
- # Loại bỏ các folder/file rác do hệ điều hành sinh ra (ví dụ: .DS_Store của macOS) hoặc không phải thư mục
+ # 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]]:
- """Lấy/Tải dữ liệu Agent Profile do AI sinh ra dựa theo nền tảng mạng xã hội"""
+ """
+ Đọ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 []
-
+ 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]]:
- """Lấy thông số cấu hình của bản mô phỏng"""
+ """
+ Đọ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]:
- """Output ra hướng dẫn / Các câu lệnh dòng lệnh (CMD) để thực thi chạy bản đồ mô phỏng này"""
+ """
+ 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,
diff --git a/backend/app/services/zep_entity_reader.py b/backend/app/services/zep_entity_reader.py
index 38d9bdca..248d386a 100644
--- a/backend/app/services/zep_entity_reader.py
+++ b/backend/app/services/zep_entity_reader.py
@@ -1,6 +1,33 @@
"""
Dịch vụ đọc và lọc thực thể Zep
Đọc các node từ đồ thị Zep, lọc ra các node phù hợp với các loại thực thể đã được định nghĩa trước
+
+Vị trí trong pipeline (ai gọi file này?):
+─────────────────────────────────────────────────────────────────────────────
+ simulation_manager.py → filter_defined_entities() [caller chính]
+ └─ prepare_simulation() gọi ở Giai đoạn 1 để lấy entity làm agent
+
+ oasis_profile_generator.py → get_entity_with_context()
+ └─ khi cần đọc sâu thêm ngữ cảnh của 1 entity cụ thể để sinh persona
+
+ simulation_config_generator.py → nhận FilteredEntities.entities làm input
+ └─ không gọi trực tiếp, dùng kết quả đã lọc từ simulation_manager
+
+ api/simulation.py → qua SimulationManager, không gọi trực tiếp
+─────────────────────────────────────────────────────────────────────────────
+
+Luồng dữ liệu từ Zep vào hệ thống:
+ Zep Graph (cloud)
+ │
+ ├── graph.node.get_by_graph_id() ─→ get_all_nodes() ─┐
+ │ (phân trang qua zep_paging.fetch_all_nodes) │
+ │ ├→ filter_defined_entities()
+ └── graph.edge.get_by_graph_id() ─→ get_all_edges() ─┘
+ (phân trang qua zep_paging.fetch_all_edges)
+ │
+ ▼
+ FilteredEntities
+ └─ entities: List[EntityNode] ← input cho OasisProfileGenerator
"""
import time
@@ -15,24 +42,52 @@ from ..utils.zep_paging import fetch_all_nodes, fetch_all_edges
logger = get_logger('mirofish.zep_entity_reader')
-# Dùng cho các kiểu trả về generic
+# TypeVar để _call_with_retry có thể trả về đúng kiểu dữ liệu của hàm được truyền vào
T = TypeVar('T')
+# ==============================================================================
+# DATACLASS: EntityNode — Đơn vị dữ liệu cơ bản của một thực thể
+# ==============================================================================
+# Mỗi EntityNode tương ứng với 1 node trong Zep Graph, đã được enrich thêm
+# thông tin về edges và các node liên kết lân cận.
+#
+# Sau khi filter_defined_entities() chạy xong, mỗi EntityNode này sẽ được
+# OasisProfileGenerator chuyển đổi thành 1 OasisAgentProfile (1 agent trong OASIS).
+# ==============================================================================
+
@dataclass
class EntityNode:
"""Cấu trúc dữ liệu của node thực thể"""
- uuid: str
- name: str
+
+ # --- Định danh từ Zep ---
+ uuid: str # UUID của node trong Zep (dùng để lookup edges)
+ name: str # Tên hiển thị của entity (ví dụ: "Nguyễn Văn A", "Bộ Giáo Dục")
+
+ # --- Phân loại ---
+ # Zep gắn label mặc định "Entity" cho mọi node.
+ # Node có loại cụ thể sẽ có thêm label như "Person", "Organization", "Event"...
+ # Ví dụ: ["Entity", "Person"] hoặc ["Entity", "Organization"]
labels: List[str]
+
+ # --- Nội dung ---
+ # summary: Đoạn mô tả tóm tắt về entity, do Zep tự sinh khi trích xuất từ văn bản
summary: str
+ # attributes: Các thuộc tính bổ sung dạng key-value (tuổi, nghề nghiệp, v.v.)
attributes: Dict[str, Any]
- # Thông tin edge liên quan
+
+ # --- Ngữ cảnh mối quan hệ (được enrich khi enrich_with_edges=True) ---
+ # related_edges: Danh sách các quan hệ mà entity này tham gia
+ # Mỗi edge có: direction ("incoming"/"outgoing"), edge_name, fact, target/source_node_uuid
+ # Ví dụ: {"direction": "outgoing", "edge_name": "WORKS_AT", "fact": "A làm việc tại B", ...}
related_edges: List[Dict[str, Any]] = field(default_factory=list)
- # Thông tin các node khác liên quan
+
+ # related_nodes: Thông tin cơ bản của các node ở đầu kia của related_edges
+ # Giúp LLM biết entity này liên kết với những ai/gì mà không cần fetch thêm
related_nodes: List[Dict[str, Any]] = field(default_factory=list)
-
+
def to_dict(self) -> Dict[str, Any]:
+ """Serialize toàn bộ EntityNode thành dict — dùng để truyền vào LLM prompt."""
return {
"uuid": self.uuid,
"name": self.name,
@@ -42,23 +97,41 @@ class EntityNode:
"related_edges": self.related_edges,
"related_nodes": self.related_nodes,
}
-
+
def get_entity_type(self) -> Optional[str]:
- """Lấy loại thực thể (loại trừ nhãn Entity mặc định)"""
+ """
+ Trả về loại thực thể đầu tiên tìm thấy (loại trừ label mặc định "Entity" và "Node").
+
+ Ví dụ:
+ labels = ["Entity", "Person"] → trả về "Person"
+ labels = ["Entity", "Node"] → trả về None (không có loại cụ thể)
+ labels = ["Entity"] → trả về None
+
+ Dùng để nhóm agent theo loại khi sinh prompt.
+ """
for label in self.labels:
if label not in ["Entity", "Node"]:
return label
return None
+# ==============================================================================
+# DATACLASS: FilteredEntities — Kết quả đầu ra của bước đọc và lọc
+# ==============================================================================
+# Đây là "gói hàng" được trả về cho simulation_manager.py sau khi đọc Zep.
+# simulation_manager lưu entities_count và entity_types vào SimulationState,
+# rồi truyền entities vào OasisProfileGenerator và SimulationConfigGenerator.
+# ==============================================================================
+
@dataclass
class FilteredEntities:
"""Tập hợp các thực thể sau khi lọc"""
- entities: List[EntityNode]
- entity_types: Set[str]
- total_count: int
- filtered_count: int
-
+
+ entities: List[EntityNode] # Danh sách entity đã lọc và enrich — input cho profile/config generator
+ entity_types: Set[str] # Tập hợp các loại entity có mặt (ví dụ: {"Person", "Organization"})
+ total_count: int # Tổng số node đọc được từ Zep (trước khi lọc)
+ filtered_count: int # Số node còn lại sau khi lọc (bằng len(entities))
+
def to_dict(self) -> Dict[str, Any]:
return {
"entities": [e.to_dict() for e in self.entities],
@@ -68,45 +141,72 @@ class FilteredEntities:
}
+# ==============================================================================
+# CLASS: ZepEntityReader — Cầu nối giữa Zep Cloud API và pipeline MiroFish
+# ==============================================================================
+# Khởi tạo 1 instance mỗi lần gọi prepare_simulation() (stateless, không có cache).
+# Toàn bộ kết nối Zep đi qua đây — không service nào khác gọi trực tiếp Zep client
+# để đọc nodes/edges.
+# ==============================================================================
+
class ZepEntityReader:
"""
Dịch vụ đọc và lọc thực thể Zep
-
+
Chức năng chính:
- 1. Đọc toàn bộ các node từ đồ thị Zep
- 2. Lọc ra các node phù hợp với các loại thực thể đã được định nghĩa (Các node có Labels không chỉ là Entity)
- 3. Lấy ra thông tin edge cũng như các node liên quan đối với từng thực thể
+ 1. Đọc toàn bộ các node từ đồ thị Zep (có phân trang, tối đa 2000 nodes)
+ 2. Lọc ra các node phù hợp với các loại thực thể đã được định nghĩa
+ (Các node có Labels không chỉ là "Entity"/"Node")
+ 3. Enrich mỗi entity với edges và related_nodes lân cận
"""
-
+
def __init__(self, api_key: Optional[str] = None):
+ # Ưu tiên api_key được truyền vào trực tiếp; fallback về biến môi trường
self.api_key = api_key or Config.ZEP_API_KEY
if not self.api_key:
raise ValueError("ZEP_API_KEY is not configured")
-
+
self.client = Zep(api_key=self.api_key)
-
+
+ # --------------------------------------------------------------------------
+ # PRIVATE: _call_with_retry — Retry wrapper cho các API call Zep đơn lẻ
+ # --------------------------------------------------------------------------
+ # Lưu ý: hàm này CHỈ được dùng bởi get_node_edges() và get_entity_with_context().
+ # filter_defined_entities() KHÔNG dùng hàm này — nó dùng fetch_all_nodes/edges
+ # từ zep_paging.py, vốn đã có retry riêng ở cấp trang.
+ # --------------------------------------------------------------------------
+
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:
"""
- Gọi hàm Zep API có cơ chế thử lại (retry)
-
+ Gọi một hàm Zep API với cơ chế thử lại (exponential backoff).
+
+ Chiến lược retry:
+ Lần 1: chạy ngay
+ Lần 2 (nếu lần 1 lỗi): chờ 2 giây
+ Lần 3 (nếu lần 2 lỗi): chờ 4 giây
+ Sau 3 lần đều lỗi → raise exception cuối cùng
+
Args:
- func: Hàm cần thực thi (lambda không tham số hoặc callable)
- operation_name: Tên thao tác, dùng cho log
- max_retries: Số lần thử lại tối đa (mặc định 3 lần, tức là thử tối đa 3 lần)
- initial_delay: Số giây trì hoãn ban đầu
-
+ func: Lambda/callable không tham số bọc lệnh gọi API
+ operation_name: Tên thao tác để ghi vào log (ví dụ: "Fetch node edges")
+ max_retries: Số lần thử tối đa (mặc định 3)
+ initial_delay: Delay ban đầu tính bằng giây (tự nhân đôi mỗi lần)
+
Returns:
- Kết quả của lệnh gọi API
+ Kết quả trả về từ func() nếu thành công
+
+ Raises:
+ Exception: Exception cuối cùng sau khi hết số lần thử
"""
last_exception = None
delay = initial_delay
-
+
for attempt in range(max_retries):
try:
return func()
@@ -118,26 +218,45 @@ class ZepEntityReader:
f"retrying in {delay:.1f} seconds..."
)
time.sleep(delay)
- delay *= 2 # Lùi bước nhịp mũ (Exponential backoff)
+ delay *= 2 # Exponential backoff: 2s → 4s → 8s
else:
logger.error(f"Zep {operation_name} failed after {max_retries} attempts: {str(e)}")
-
+
raise last_exception
-
+
+ # --------------------------------------------------------------------------
+ # PUBLIC: get_all_nodes / get_all_edges — Bulk fetch toàn bộ nodes và edges
+ # --------------------------------------------------------------------------
+ # Hai hàm này là wrapper mỏng quanh fetch_all_nodes/fetch_all_edges từ
+ # zep_paging.py. Logic phân trang và retry theo trang nằm hoàn toàn trong
+ # zep_paging.py (UUID cursor-based pagination, tối đa 2000 nodes).
+ #
+ # Tại sao dùng bulk fetch thay vì fetch từng node?
+ # → Hiệu quả hơn nhiều: 2 API calls cho toàn bộ graph thay vì N calls
+ # → filter_defined_entities() dùng chiến lược này
+ # --------------------------------------------------------------------------
+
def get_all_nodes(self, graph_id: str) -> List[Dict[str, Any]]:
"""
- Lấy toàn bộ các node của đồ thị (có phân trang)
+ Lấy toàn bộ các node của đồ thị bằng cách phân trang.
+
+ Uỷ thác cho fetch_all_nodes() (zep_paging.py) để xử lý:
+ - Cursor-based pagination (100 nodes/trang)
+ - Retry từng trang khi lỗi mạng
+ - Giới hạn tổng 2000 nodes
Args:
- graph_id: ID của đồ thị
+ graph_id: ID của Zep Graph
Returns:
- Danh sách node
+ Danh sách dict, mỗi dict là 1 node với: uuid, name, labels, summary, attributes
"""
logger.info(f"Fetching all nodes for graph {graph_id}...")
nodes = fetch_all_nodes(self.client, graph_id)
+ # Chuẩn hoá từ Zep SDK object sang Python dict thuần
+ # getattr với 2 tên vì Zep SDK đôi khi dùng uuid_ thay vì uuid để tránh conflict keyword
nodes_data = []
for node in nodes:
nodes_data.append({
@@ -153,13 +272,17 @@ class ZepEntityReader:
def get_all_edges(self, graph_id: str) -> List[Dict[str, Any]]:
"""
- Lấy toàn bộ các edge của đồ thị (có phân trang)
+ Lấy toàn bộ các edge của đồ thị bằng cách phân trang.
+
+ Tương tự get_all_nodes() — uỷ thác cho fetch_all_edges() (zep_paging.py).
+ Không có giới hạn tổng số edge (khác với nodes giới hạn 2000).
Args:
- graph_id: ID của đồ thị
+ graph_id: ID của Zep Graph
Returns:
- Danh sách edge
+ Danh sách dict, mỗi dict là 1 edge với:
+ uuid, name, fact, source_node_uuid, target_node_uuid, attributes
"""
logger.info(f"Fetching all edges for graph {graph_id}...")
@@ -170,32 +293,45 @@ class ZepEntityReader:
edges_data.append({
"uuid": getattr(edge, 'uuid_', None) or getattr(edge, 'uuid', ''),
"name": edge.name or "",
- "fact": edge.fact or "",
- "source_node_uuid": edge.source_node_uuid,
- "target_node_uuid": edge.target_node_uuid,
+ "fact": edge.fact or "", # Mô tả quan hệ dạng câu (ví dụ: "A làm việc tại B")
+ "source_node_uuid": edge.source_node_uuid, # UUID node nguồn
+ "target_node_uuid": edge.target_node_uuid, # UUID node đích
"attributes": edge.attributes or {},
})
logger.info(f"Total {len(edges_data)} edges fetched")
return edges_data
-
+
+ # --------------------------------------------------------------------------
+ # PUBLIC: get_node_edges — Fetch edges của 1 node cụ thể (per-node API call)
+ # --------------------------------------------------------------------------
+ # ĐÂY KHÔNG PHẢI hàm được dùng trong filter_defined_entities().
+ # filter_defined_entities() lấy ALL edges rồi tự scan — không gọi hàm này.
+ #
+ # Hàm này chỉ được dùng bởi get_entity_with_context() khi cần fetch
+ # edges cho 1 entity đơn lẻ theo UUID cụ thể.
+ # --------------------------------------------------------------------------
+
def get_node_edges(self, node_uuid: str) -> List[Dict[str, Any]]:
"""
- Lấy tất cả các edge liên quan của node được chỉ định (có cơ chế thử lại)
-
+ Lấy tất cả edges liên quan của 1 node cụ thể qua UUID.
+
+ Dùng Zep API: client.graph.node.get_entity_edges(node_uuid=...)
+ Có retry qua _call_with_retry() (không qua zep_paging vì không phân trang).
+
Args:
- node_uuid: UUID của node
-
+ node_uuid: UUID của node cần lấy edges
+
Returns:
- Danh sách edge
+ Danh sách edge dict (cùng format với get_all_edges()).
+ Trả về [] nếu lỗi (không raise exception — caller tự xử lý thiếu data).
"""
try:
- # Sử dụng cơ chế thử lại để gọi Zep API
edges = self._call_with_retry(
func=lambda: self.client.graph.node.get_entity_edges(node_uuid=node_uuid),
operation_name=f"Fetch node edges(node={node_uuid[:8]}...)"
)
-
+
edges_data = []
for edge in edges:
edges_data.append({
@@ -206,71 +342,149 @@ class ZepEntityReader:
"target_node_uuid": edge.target_node_uuid,
"attributes": edge.attributes or {},
})
-
+
return edges_data
except Exception as e:
+ # Lỗi khi fetch edge của 1 node không nên làm hỏng cả pipeline
+ # → log warning và trả về rỗng, caller (get_entity_with_context) tiếp tục
logger.warning(f"Failed to fetch edges for node {node_uuid}: {str(e)}")
return []
-
+
+ # --------------------------------------------------------------------------
+ # PUBLIC: filter_defined_entities — Hàm CHÍNH của class
+ # --------------------------------------------------------------------------
+ # Đây là hàm được gọi bởi simulation_manager.prepare_simulation() ở Giai đoạn 1.
+ # Toàn bộ pipeline phụ thuộc vào output của hàm này.
+ #
+ # Thuật toán:
+ # 1. Bulk fetch TẤT CẢ nodes (2 API calls qua phân trang)
+ # 2. Bulk fetch TẤT CẢ edges (nếu enrich_with_edges=True)
+ # 3. Duyệt từng node, áp dụng logic lọc label
+ # 4. Với mỗi node hợp lệ: scan toàn bộ all_edges để tìm edges liên quan
+ # 5. Trả về FilteredEntities
+ #
+ # Độ phức tạp: O(N * M) với N = số node lọc được, M = tổng số edge
+ # Trong thực tế graph nhỏ (< 100 nodes, < 500 edges) nên không ảnh hưởng
+ # --------------------------------------------------------------------------
+
def filter_defined_entities(
- self,
+ self,
graph_id: str,
defined_entity_types: Optional[List[str]] = None,
enrich_with_edges: bool = True
) -> FilteredEntities:
"""
- Lọc ra các node phù hợp với các loại thực thể đã được định nghĩa
-
- Logic lọc:
- - Nếu Labels của node chỉ có một nhãn là "Entity", tức là thực thể này không hợp với loại chúng ta định nghĩa, tiến hành bỏ qua
- - Nếu Labels của node chứa các nhãn khác ngoài "Entity" và "Node", tức là hợp lệ, tiến hành giữ lại
-
+ Đọc toàn bộ graph từ Zep rồi lọc ra các entity có loại được định nghĩa.
+
+ Logic lọc label (quan trọng — đây là cách phân biệt entity "có ý nghĩa"):
+ ┌──────────────────────────────────────────────────────┐
+ │ Node trong Zep có 2 loại label: │
+ │ │
+ │ "Generic" node: labels = ["Entity"] hoặc ["Node"] │
+ │ → Zep tạo ra khi trích xuất context chung, │
+ │ không phải thực thể cụ thể nào → BỎ QUA │
+ │ │
+ │ "Typed" node: labels = ["Entity", "Person"] │
+ │ labels = ["Entity", "Organization"] │
+ │ → Thực thể được định danh rõ ràng → GIỮ LẠI │
+ └──────────────────────────────────────────────────────┘
+
+ Hai chế độ lọc:
+ - defined_entity_types=None: giữ TẤT CẢ typed nodes (mọi custom label đều OK)
+ - defined_entity_types=["Person", "Organization"]: chỉ giữ nodes có label trong danh sách
+
+ Enrich với edges:
+ - Sau khi lọc, với mỗi entity, scan all_edges để tìm edges có
+ source_node_uuid hoặc target_node_uuid trùng với entity.uuid
+ - Phân loại: source = outgoing, target = incoming
+ - Lấy thêm thông tin cơ bản của node ở đầu kia (tên, labels, summary)
+
+ Ví dụ minh hoạ:
+ ─────────────────────────────────────────────────────────────────────
+ Giả sử Zep Graph có 4 nodes và 2 edges sau:
+
+ NODES:
+ uuid="aaa", name="Nguyễn Văn A", labels=["Entity", "Person"]
+ uuid="bbb", name="Bộ Giáo Dục", labels=["Entity", "Organization"]
+ uuid="ccc", name="context-001", labels=["Entity"] ← generic, bị loại
+ uuid="ddd", name="Hà Nội", labels=["Entity", "Location"]
+
+ EDGES:
+ source="aaa" → target="bbb", fact="Nguyễn Văn A làm việc tại Bộ Giáo Dục"
+ source="ddd" → target="aaa", fact="Nguyễn Văn A sinh sống tại Hà Nội"
+
+ ── Gọi với defined_entity_types=None ──────────────────────────────
+ filter_defined_entities(graph_id, defined_entity_types=None)
+
+ → Giữ lại: "aaa" (Person), "bbb" (Organization), "ddd" (Location)
+ → Loại bỏ: "ccc" (chỉ có label "Entity")
+ → entity_types = {"Person", "Organization", "Location"}
+ → filtered_count = 3, total_count = 4
+
+ EntityNode uuid="aaa" (Nguyễn Văn A) sau enrich:
+ related_edges = [
+ {"direction": "outgoing", "edge_name": "...", "fact": "A làm việc tại Bộ GD", "target_node_uuid": "bbb"},
+ {"direction": "incoming", "edge_name": "...", "fact": "A sinh sống tại Hà Nội", "source_node_uuid": "ddd"},
+ ]
+ related_nodes = [
+ {"uuid": "bbb", "name": "Bộ Giáo Dục", "labels": ["Entity", "Organization"], "summary": "..."},
+ {"uuid": "ddd", "name": "Hà Nội", "labels": ["Entity", "Location"], "summary": "..."},
+ ]
+
+ ── Gọi với defined_entity_types=["Person"] ─────────────────────────
+ filter_defined_entities(graph_id, defined_entity_types=["Person"])
+
+ → Giữ lại: chỉ "aaa" (Person)
+ → Loại bỏ: "bbb" (Organization không match), "ccc" (generic), "ddd" (Location không match)
+ → filtered_count = 1
+ ─────────────────────────────────────────────────────────────────────
+
Args:
- graph_id: ID của đồ thị
- defined_entity_types: Danh sách các loại thực thể định nghĩa trước (không bắt buộc, nếu có thì chỉ giữ lại các loại đó)
- enrich_with_edges: Có lấy thông tin edge liên quan của từng thực thể hay không
-
+ graph_id: ID của Zep Graph
+ defined_entity_types: Danh sách loại cần lọc (None = lấy tất cả)
+ enrich_with_edges: True = thêm related_edges + related_nodes vào mỗi entity
+
Returns:
- FilteredEntities: Tập hợp các thực thể sau khi lọc
+ FilteredEntities chứa danh sách EntityNode đã enrich
"""
logger.info(f"Start filtering entities for graph {graph_id}...")
-
- # Lấy toàn bộ các node
+
+ # --- Bước 1: Bulk fetch nodes và edges ---
all_nodes = self.get_all_nodes(graph_id)
total_count = len(all_nodes)
-
- # Lấy toàn bộ các edge (để lấy liên kết sau này)
+
+ # Chỉ fetch edges nếu cần enrich (tiết kiệm 1 API call nếu không cần)
all_edges = self.get_all_edges(graph_id) if enrich_with_edges else []
-
- # Xây dựng map ánh xạ từ UUID của node sang dữ liệu node
+
+ # Xây dựng dict ánh xạ uuid → node data để tra cứu O(1) khi enrich related_nodes
node_map = {n["uuid"]: n for n in all_nodes}
-
- # Lọc các thực thể đáp ứng điều kiện
+
+ # --- Bước 2: Lọc nodes theo label ---
filtered_entities = []
entity_types_found = set()
-
+
for node in all_nodes:
labels = node.get("labels", [])
-
- # Logic lọc: Labels bắt buộc phải chứa các nhãn khác "Entity" và "Node"
+
+ # Tìm custom labels (loại trừ "Entity" và "Node" là labels mặc định của Zep)
custom_labels = [l for l in labels if l not in ["Entity", "Node"]]
-
+
if not custom_labels:
- # Chỉ có nhãn mặc định, bỏ qua
+ # Node này chỉ có labels mặc định → không phải entity cụ thể → bỏ qua
continue
-
- # Nếu đã chỉ định loại thực thể cho trước, kiểm tra xem có khớp hay không
+
+ # Nếu người dùng chỉ định danh sách loại, kiểm tra xem node có match không
if defined_entity_types:
matching_labels = [l for l in custom_labels if l in defined_entity_types]
if not matching_labels:
- continue
- entity_type = matching_labels[0]
+ continue # Node này có custom label nhưng không nằm trong danh sách cho phép
+ entity_type = matching_labels[0] # Lấy loại match đầu tiên
else:
- entity_type = custom_labels[0]
-
+ entity_type = custom_labels[0] # Lấy custom label đầu tiên làm loại
+
entity_types_found.add(entity_type)
-
- # Tạo object cho node thực thể
+
+ # Tạo EntityNode (related_edges và related_nodes vẫn rỗng, enrich ở bước sau)
entity = EntityNode(
uuid=node["uuid"],
name=node["name"],
@@ -278,14 +492,17 @@ class ZepEntityReader:
summary=node["summary"],
attributes=node["attributes"],
)
-
- # Lấy các edge và node liên quan
+
+ # --- Bước 3: Enrich với edges và related nodes ---
if enrich_with_edges:
related_edges = []
related_node_uuids = set()
-
+
+ # Scan toàn bộ all_edges để tìm edges có liên quan đến node này
+ # (không gọi get_node_edges() vì đã có all_edges trong memory)
for edge in all_edges:
if edge["source_node_uuid"] == node["uuid"]:
+ # Entity này là nguồn → quan hệ "outgoing" (chiều đi ra)
related_edges.append({
"direction": "outgoing",
"edge_name": edge["name"],
@@ -293,7 +510,9 @@ class ZepEntityReader:
"target_node_uuid": edge["target_node_uuid"],
})
related_node_uuids.add(edge["target_node_uuid"])
+
elif edge["target_node_uuid"] == node["uuid"]:
+ # Entity này là đích → quan hệ "incoming" (chiều đi vào)
related_edges.append({
"direction": "incoming",
"edge_name": edge["name"],
@@ -301,10 +520,10 @@ class ZepEntityReader:
"source_node_uuid": edge["source_node_uuid"],
})
related_node_uuids.add(edge["source_node_uuid"])
-
+
entity.related_edges = related_edges
-
- # Lấy thông tin cơ bản của các node được liên kết
+
+ # Lấy thông tin cơ bản của các node lân cận (không lấy full để tránh vòng lặp)
related_nodes = []
for related_uuid in related_node_uuids:
if related_uuid in node_map:
@@ -314,58 +533,72 @@ class ZepEntityReader:
"name": related_node["name"],
"labels": related_node["labels"],
"summary": related_node.get("summary", ""),
+ # Không lấy attributes để tránh payload quá lớn khi truyền vào LLM
})
-
+
entity.related_nodes = related_nodes
-
+
filtered_entities.append(entity)
-
+
logger.info(f"Filtering completed: Total nodes {total_count}, Matched {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),
)
-
+
+ # --------------------------------------------------------------------------
+ # PUBLIC: get_entity_with_context — Fetch 1 entity đơn lẻ theo UUID
+ # --------------------------------------------------------------------------
+ # Dùng bởi oasis_profile_generator.py khi cần đọc sâu thêm 1 entity cụ thể.
+ # Khác với filter_defined_entities():
+ # - Fetch 1 node theo UUID (không scan toàn bộ graph)
+ # - Dùng get_node_edges() per-node (per-node API call)
+ # - Nhưng vẫn cần fetch all_nodes để build related_nodes → tốn hơn
+ # --------------------------------------------------------------------------
+
def get_entity_with_context(
- self,
- graph_id: str,
+ self,
+ graph_id: str,
entity_uuid: str
) -> Optional[EntityNode]:
"""
- Lấy thông tin của một thực thể cụ thể và ngữ cảnh đầy đủ của nó (edge và node liên kết, với cơ chế thử lại)
-
+ Lấy thông tin đầy đủ của 1 entity cụ thể và toàn bộ ngữ cảnh liên kết.
+
+ Dùng per-node API call (khác với filter_defined_entities dùng bulk fetch).
+ Phù hợp khi chỉ cần 1 entity — không hiệu quả nếu cần nhiều entities.
+
Args:
- graph_id: ID của đồ thị
- entity_uuid: UUID của thực thể
-
+ graph_id: ID của Zep Graph (dùng để lấy node_map cho related_nodes)
+ entity_uuid: UUID của entity cần lấy
+
Returns:
- EntityNode hoặc None
+ EntityNode đầy đủ hoặc None nếu không tìm thấy / lỗi
"""
try:
- # Sử dụng cơ chế thử lại để lấy thông tin node
+ # Fetch node theo UUID với retry
node = self._call_with_retry(
func=lambda: self.client.graph.node.get(uuid_=entity_uuid),
operation_name=f"Fetch node detail(uuid={entity_uuid[:8]}...)"
)
-
+
if not node:
return None
-
- # Lấy các edge của node
+
+ # Fetch edges của node này (per-node call, có retry)
edges = self.get_node_edges(entity_uuid)
-
- # Lấy tất cả các node để tìm liên kết
+
+ # Cần all_nodes để build related_nodes map — đây là điểm tốn kém nhất
all_nodes = self.get_all_nodes(graph_id)
node_map = {n["uuid"]: n for n in all_nodes}
-
- # Xử lý các edge và node liên quan
+
+ # Xử lý edges (cùng logic với filter_defined_entities)
related_edges = []
related_node_uuids = set()
-
+
for edge in edges:
if edge["source_node_uuid"] == entity_uuid:
related_edges.append({
@@ -383,8 +616,7 @@ class ZepEntityReader:
"source_node_uuid": edge["source_node_uuid"],
})
related_node_uuids.add(edge["source_node_uuid"])
-
- # Lấy thông tin về node được liên kết
+
related_nodes = []
for related_uuid in related_node_uuids:
if related_uuid in node_map:
@@ -395,7 +627,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 +637,34 @@ class ZepEntityReader:
related_edges=related_edges,
related_nodes=related_nodes,
)
-
+
except Exception as e:
logger.error(f"Failed to fetch entity {entity_uuid}: {str(e)}")
return None
-
+
+ # --------------------------------------------------------------------------
+ # PUBLIC: get_entities_by_type — Convenience wrapper cho 1 loại entity cụ thể
+ # --------------------------------------------------------------------------
+
def get_entities_by_type(
- self,
- graph_id: str,
+ self,
+ graph_id: str,
entity_type: str,
enrich_with_edges: bool = True
) -> List[EntityNode]:
"""
- Lấy tất cả các thực thể dựa theo loại cụ thể
-
+ Lấy tất cả entities của một loại cụ thể.
+
+ Là thin wrapper quanh filter_defined_entities() với defined_entity_types=[entity_type].
+ Tiện dụng khi chỉ cần 1 loại thay vì nhiều loại.
+
Args:
- graph_id: ID của đồ thị
- entity_type: Loại thực thể (ví dụ: "Student", "PublicFigure", v.v..)
- enrich_with_edges: Có lấy thông tin edge liên quan hay không
-
+ graph_id: ID của Zep Graph
+ entity_type: Loại entity cần lọc (ví dụ: "Person", "Organization")
+ enrich_with_edges: Có enrich với edges không
+
Returns:
- Danh sách thực thể
+ Danh sách EntityNode thuộc loại đã chỉ định
"""
result = self.filter_defined_entities(
graph_id=graph_id,
@@ -433,5 +672,3 @@ class ZepEntityReader:
enrich_with_edges=enrich_with_edges
)
return result.entities
-
-
diff --git a/backend/app/services/zep_tools.py b/backend/app/services/zep_tools.py
index 4de8331b..27fedc94 100644
--- a/backend/app/services/zep_tools.py
+++ b/backend/app/services/zep_tools.py
@@ -1419,7 +1419,7 @@ Trả về danh sách các câu hỏi phụ dưới định dạng JSON."""
simulation_id=simulation_id,
interviews=interviews_request,
platform=None, # Không định dạng nền tảng -> Dual platform call
- timeout=180.0 # Tăng timeout do phải chờ API trên 2 platforms xử lý
+ timeout= 800 # Tăng timeout do phải chờ API trên 2 platforms xử lý
)
logger.info(f"Interview API returned: {api_result.get('interviews_count', 0)} results, success={api_result.get('success')}")
@@ -1832,7 +1832,7 @@ Hãy tạo bản tóm tắt phỏng vấn."""
{"role": "user", "content": user_prompt}
],
temperature=0.3,
- max_tokens=800
+ max_tokens=4096
)
return summary
diff --git a/backend/app/utils/llm_client.py b/backend/app/utils/llm_client.py
index 987959c5..4d923cad 100644
--- a/backend/app/utils/llm_client.py
+++ b/backend/app/utils/llm_client.py
@@ -79,7 +79,7 @@ class LLMClient:
metadata=call_metadata,
**{k: v for k, v in kwargs.items() if k not in {"model", "messages"}},
)
- content = response.choices[0].message.content
+ content = response.choices[0].message.content or ""
# Một số model (vd MiniMax M2.5) chèn nội dung vào content, cần loại bỏ
content = re.sub(r'[\s\S]*?', '', content).strip()
return content
@@ -88,7 +88,7 @@ class LLMClient:
self,
messages: List[Dict[str, str]],
temperature: float = 0.3,
- max_tokens: int = 4096,
+ max_tokens: int = 50000,
metadata: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""
diff --git a/backend/scripts/run_parallel_simulation.py b/backend/scripts/run_parallel_simulation.py
index fb9eda66..c0953f6a 100644
--- a/backend/scripts/run_parallel_simulation.py
+++ b/backend/scripts/run_parallel_simulation.py
@@ -1057,6 +1057,7 @@ def create_model(config: Dict[str, Any], use_boost: bool = False):
return ModelFactory.create(
model_platform=ModelPlatformType.OPENAI,
model_type=llm_model,
+ timeout=1000
)
@@ -1179,7 +1180,7 @@ async def run_twitter_simulation(
agent_graph=result.agent_graph,
platform=oasis.DefaultPlatformType.TWITTER,
database_path=db_path,
- semaphore=30, # Giới hạn số request LLM đồng thời để tránh quá tải API
+ semaphore=3, # Giới hạn số request LLM đồng thời để tránh quá tải API
)
await result.env.reset()
@@ -1370,7 +1371,7 @@ async def run_reddit_simulation(
agent_graph=result.agent_graph,
platform=oasis.DefaultPlatformType.REDDIT,
database_path=db_path,
- semaphore=30, # Giới hạn số request LLM đồng thời để tránh quá tải API
+ semaphore=3, # Giới hạn số request LLM đồng thời để tránh quá tải API
)
await result.env.reset()
diff --git a/backend/scripts/test_entity_reader.py b/backend/scripts/test_entity_reader.py
new file mode 100644
index 00000000..db456fe3
--- /dev/null
+++ b/backend/scripts/test_entity_reader.py
@@ -0,0 +1,137 @@
+"""
+Script test ZepEntityReader — đọc graph đã build, xem logic chọn entity làm agent
+Chạy từ thư mục backend/
+
+Usage:
+ python scripts/test_entity_reader.py
+ python scripts/test_entity_reader.py --type Student
+ python scripts/test_entity_reader.py --uuid
+ python scripts/test_entity_reader.py --from-log case_01_academic_scandal
+"""
+import sys
+import json
+import os
+
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+from app.services.zep_entity_reader import ZepEntityReader
+
+OUTPUT_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "logs")
+
+
+def load_graph_id_from_log(case_id: str) -> str:
+ log_path = os.path.join(OUTPUT_DIR, f"test_graph_output_{case_id}.json")
+ if not os.path.exists(log_path):
+ print(f"Không tìm thấy file log: {log_path}")
+ sys.exit(1)
+ with open(log_path, encoding="utf-8") as f:
+ data = json.load(f)
+ return data["graph_id"]
+
+
+def print_entity(entity, show_edges: bool = True):
+ entity_type = entity.get_entity_type() or "(unknown)"
+ print(f"\n ── [{entity_type}] {entity.name}")
+ print(f" uuid : {entity.uuid}")
+ if entity.summary:
+ print(f" summary : {entity.summary[:120]}")
+ if entity.attributes:
+ for k, v in entity.attributes.items():
+ if k not in ("name",) and v and v != "null":
+ print(f" attr : {k} = {v}")
+ if show_edges and entity.related_edges:
+ print(f" edges ({len(entity.related_edges)}):")
+ for e in entity.related_edges:
+ arrow = "→" if e["direction"] == "outgoing" else "←"
+ print(f" {arrow} [{e['edge_name']}] {e.get('fact', '')[:80]}")
+ if entity.related_nodes:
+ names = [n["name"] for n in entity.related_nodes]
+ print(f" related : {', '.join(names)}")
+
+
+def cmd_all(reader: ZepEntityReader, graph_id: str):
+ """Lấy tất cả entity hợp lệ (có custom label) → danh sách agent tiềm năng"""
+ print(f"\nGraph: {graph_id}")
+ print("Đang lọc entity...")
+ result = reader.filter_defined_entities(graph_id, enrich_with_edges=True)
+
+ print(f"\n{'=' * 60}")
+ print(f"Tổng nodes : {result.total_count}")
+ print(f"Entity hợp lệ : {result.filtered_count} ← đây là các agent tiềm năng")
+ print(f"Entity types : {sorted(result.entity_types)}")
+ print(f"{'=' * 60}")
+
+ # Nhóm theo type để dễ đọc
+ by_type: dict = {}
+ for e in result.entities:
+ t = e.get_entity_type() or "unknown"
+ by_type.setdefault(t, []).append(e)
+
+ for entity_type, entities in sorted(by_type.items()):
+ print(f"\n[{entity_type}] — {len(entities)} entity")
+ for e in entities:
+ print_entity(e)
+
+ # Lưu output
+ os.makedirs(OUTPUT_DIR, exist_ok=True)
+ out_path = os.path.join(OUTPUT_DIR, f"test_entity_reader_{graph_id}.json")
+ with open(out_path, "w", encoding="utf-8") as f:
+ json.dump(result.to_dict(), f, ensure_ascii=False, indent=2)
+ print(f"\nĐã lưu: {out_path}")
+
+
+def cmd_by_type(reader: ZepEntityReader, graph_id: str, entity_type: str):
+ print(f"\nLấy entity type [{entity_type}] từ graph {graph_id}...")
+ entities = reader.get_entities_by_type(graph_id, entity_type, enrich_with_edges=True)
+ print(f"Tìm thấy {len(entities)} entity loại [{entity_type}]:")
+ for e in entities:
+ print_entity(e)
+
+
+def cmd_by_uuid(reader: ZepEntityReader, graph_id: str, uuid: str):
+ print(f"\nLấy chi tiết entity {uuid}...")
+ entity = reader.get_entity_with_context(graph_id, uuid)
+ if not entity:
+ print("Không tìm thấy entity.")
+ return
+ print_entity(entity, show_edges=True)
+
+
+def main():
+ args = sys.argv[1:]
+ graph_id = None
+ entity_type = None
+ entity_uuid = None
+
+ if not args:
+ print(__doc__)
+ sys.exit(1)
+
+ if args[0] == "--from-log":
+ if len(args) < 2:
+ print("Usage: --from-log ")
+ sys.exit(1)
+ graph_id = load_graph_id_from_log(args[1])
+ print(f"Graph ID từ log [{args[1]}]: {graph_id}")
+ args = args[2:]
+ else:
+ graph_id = args[0]
+ args = args[1:]
+
+ if "--type" in args:
+ entity_type = args[args.index("--type") + 1]
+ if "--uuid" in args:
+ entity_uuid = args[args.index("--uuid") + 1]
+
+ reader = ZepEntityReader()
+
+ if entity_uuid:
+ cmd_by_uuid(reader, graph_id, entity_uuid)
+ elif entity_type:
+ cmd_by_type(reader, graph_id, entity_type)
+ else:
+ cmd_all(reader, graph_id)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/install.cmd b/install.cmd
new file mode 100644
index 00000000..cee34fd9
--- /dev/null
+++ b/install.cmd
@@ -0,0 +1,221 @@
+@echo off
+setlocal enabledelayedexpansion
+
+REM Claude Code Windows CMD Bootstrap Script
+REM Installs Claude Code for environments where PowerShell is not available
+
+REM Parse command line argument
+set "TARGET=%~1"
+if "!TARGET!"=="" set "TARGET=latest"
+
+REM Validate target parameter
+if /i "!TARGET!"=="stable" goto :target_valid
+if /i "!TARGET!"=="latest" goto :target_valid
+echo !TARGET! | findstr /r "^[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*" >nul
+if !ERRORLEVEL! equ 0 goto :target_valid
+
+echo Usage: %0 [stable^|latest^|VERSION] >&2
+echo Example: %0 1.0.58 >&2
+exit /b 1
+
+:target_valid
+
+REM Check for 64-bit Windows
+if /i "%PROCESSOR_ARCHITECTURE%"=="AMD64" goto :arch_valid
+if /i "%PROCESSOR_ARCHITECTURE%"=="ARM64" goto :arch_valid
+if /i "%PROCESSOR_ARCHITEW6432%"=="AMD64" goto :arch_valid
+if /i "%PROCESSOR_ARCHITEW6432%"=="ARM64" goto :arch_valid
+
+echo Claude Code does not support 32-bit Windows. Please use a 64-bit version of Windows. >&2
+exit /b 1
+
+:arch_valid
+
+REM Set constants
+set "GCS_BUCKET=https://storage.googleapis.com/claude-code-dist-86c565f3-f756-42ad-8dfa-d59b1c096819/claude-code-releases"
+set "DOWNLOAD_DIR=%USERPROFILE%\.claude\downloads"
+REM Use native ARM64 binary on ARM64 Windows, x64 otherwise
+if /i "%PROCESSOR_ARCHITECTURE%"=="ARM64" (
+ set "PLATFORM=win32-arm64"
+) else (
+ set "PLATFORM=win32-x64"
+)
+
+REM Create download directory
+if not exist "!DOWNLOAD_DIR!" mkdir "!DOWNLOAD_DIR!"
+
+REM Check for curl availability
+curl --version >nul 2>&1
+if !ERRORLEVEL! neq 0 (
+ echo curl is required but not available. Please install curl or use PowerShell installer. >&2
+ exit /b 1
+)
+
+REM Always download latest version (which has the most up-to-date installer)
+call :download_file "!GCS_BUCKET!/latest" "!DOWNLOAD_DIR!\latest"
+if !ERRORLEVEL! neq 0 (
+ echo Failed to get latest version >&2
+ exit /b 1
+)
+
+REM Read version from file
+set /p VERSION=<"!DOWNLOAD_DIR!\latest"
+del "!DOWNLOAD_DIR!\latest"
+
+REM Download manifest
+call :download_file "!GCS_BUCKET!/!VERSION!/manifest.json" "!DOWNLOAD_DIR!\manifest.json"
+if !ERRORLEVEL! neq 0 (
+ echo Failed to get manifest >&2
+ exit /b 1
+)
+
+REM Extract checksum from manifest
+call :parse_manifest "!DOWNLOAD_DIR!\manifest.json" "!PLATFORM!"
+if !ERRORLEVEL! neq 0 (
+ echo Platform !PLATFORM! not found in manifest >&2
+ del "!DOWNLOAD_DIR!\manifest.json" 2>nul
+ exit /b 1
+)
+del "!DOWNLOAD_DIR!\manifest.json"
+
+REM Download binary
+set "BINARY_PATH=!DOWNLOAD_DIR!\claude-!VERSION!-!PLATFORM!.exe"
+call :download_file "!GCS_BUCKET!/!VERSION!/!PLATFORM!/claude.exe" "!BINARY_PATH!"
+if !ERRORLEVEL! neq 0 (
+ echo Failed to download binary >&2
+ if exist "!BINARY_PATH!" del "!BINARY_PATH!"
+ exit /b 1
+)
+
+REM Verify checksum
+call :verify_checksum "!BINARY_PATH!" "!EXPECTED_CHECKSUM!"
+if !ERRORLEVEL! neq 0 (
+ echo Checksum verification failed >&2
+ del "!BINARY_PATH!"
+ exit /b 1
+)
+
+REM Run claude install to set up launcher and shell integration
+echo Setting up Claude Code...
+"!BINARY_PATH!" install "!TARGET!"
+set "INSTALL_RESULT=!ERRORLEVEL!"
+
+REM Clean up downloaded file
+REM Wait a moment for any file handles to be released
+timeout /t 1 /nobreak >nul 2>&1
+del /f "!BINARY_PATH!" >nul 2>&1
+if exist "!BINARY_PATH!" (
+ echo Warning: Could not remove temporary file: !BINARY_PATH!
+)
+
+if !INSTALL_RESULT! neq 0 (
+ echo Installation failed >&2
+ exit /b 1
+)
+
+echo.
+echo Installation complete^^!
+echo.
+exit /b 0
+
+REM ============================================================================
+REM SUBROUTINES
+REM ============================================================================
+
+:download_file
+REM Downloads a file using curl
+REM Args: %1=URL, %2=OutputPath
+set "URL=%~1"
+set "OUTPUT=%~2"
+
+curl -fsSL "!URL!" -o "!OUTPUT!"
+exit /b !ERRORLEVEL!
+
+:parse_manifest
+REM Parse JSON manifest to extract checksum for platform
+REM Args: %1=ManifestPath, %2=Platform
+set "MANIFEST_PATH=%~1"
+set "PLATFORM_NAME=%~2"
+set "EXPECTED_CHECKSUM="
+
+REM Use findstr to find platform section, then look for checksum
+set "FOUND_PLATFORM="
+set "IN_PLATFORM_SECTION="
+
+REM Read the manifest line by line
+for /f "usebackq tokens=*" %%i in ("!MANIFEST_PATH!") do (
+ set "LINE=%%i"
+
+ REM Check if this line contains our platform
+ echo !LINE! | findstr /c:"\"%PLATFORM_NAME%\":" >nul
+ if !ERRORLEVEL! equ 0 (
+ set "IN_PLATFORM_SECTION=1"
+ )
+
+ REM If we're in the platform section, look for checksum
+ if defined IN_PLATFORM_SECTION (
+ echo !LINE! | findstr /c:"\"checksum\":" >nul
+ if !ERRORLEVEL! equ 0 (
+ REM Extract checksum value
+ for /f "tokens=2 delims=:" %%j in ("!LINE!") do (
+ set "CHECKSUM_PART=%%j"
+ REM Remove quotes, whitespace, and comma
+ set "CHECKSUM_PART=!CHECKSUM_PART: =!"
+ set "CHECKSUM_PART=!CHECKSUM_PART:"=!"
+ set "CHECKSUM_PART=!CHECKSUM_PART:,=!"
+
+ REM Check if it looks like a SHA256 (64 hex chars)
+ if not "!CHECKSUM_PART!"=="" (
+ call :check_length "!CHECKSUM_PART!" 64
+ if !ERRORLEVEL! equ 0 (
+ set "EXPECTED_CHECKSUM=!CHECKSUM_PART!"
+ exit /b 0
+ )
+ )
+ )
+ )
+
+ REM Check if we've left the platform section (closing brace)
+ echo !LINE! | findstr /c:"}" >nul
+ if !ERRORLEVEL! equ 0 set "IN_PLATFORM_SECTION="
+ )
+)
+
+if "!EXPECTED_CHECKSUM!"=="" exit /b 1
+exit /b 0
+
+:check_length
+REM Check if string length equals expected length
+REM Args: %1=String, %2=ExpectedLength
+set "STR=%~1"
+set "EXPECTED_LEN=%~2"
+set "LEN=0"
+:count_loop
+if "!STR:~%LEN%,1!"=="" goto :count_done
+set /a LEN+=1
+goto :count_loop
+:count_done
+if %LEN%==%EXPECTED_LEN% exit /b 0
+exit /b 1
+
+:verify_checksum
+REM Verify file checksum using certutil
+REM Args: %1=FilePath, %2=ExpectedChecksum
+set "FILE_PATH=%~1"
+set "EXPECTED=%~2"
+
+for /f "skip=1 tokens=*" %%i in ('certutil -hashfile "!FILE_PATH!" SHA256') do (
+ set "ACTUAL=%%i"
+ set "ACTUAL=!ACTUAL: =!"
+ if "!ACTUAL!"=="CertUtil:Thecommandcompletedsuccessfully." goto :verify_done
+ if "!ACTUAL!" neq "" (
+ if /i "!ACTUAL!"=="!EXPECTED!" (
+ exit /b 0
+ ) else (
+ exit /b 1
+ )
+ )
+)
+
+:verify_done
+exit /b 1
diff --git a/oasis b/oasis
new file mode 160000
index 00000000..336c9c6e
--- /dev/null
+++ b/oasis
@@ -0,0 +1 @@
+Subproject commit 336c9c6ed7583b863ef0536a870f7d510d55502c
diff --git a/report.md b/report.md
new file mode 100644
index 00000000..cc8c40fd
--- /dev/null
+++ b/report.md
@@ -0,0 +1,186 @@
+# Báo cáo Dự báo Tương lai: Động lực Thị trường Dầu mỏ & Tài chính dưới Áp lực Địa chính trị
+
+> Mô phỏng dự báo một tương lai mà thị trường dầu mỏ sẽ duy trì biến động mạnh và giá cao trong ít nhất 6 tháng, với sự phân hóa rõ rệt giữa các tác nhân: nhà giao dịch tận dụng đòn bẩy để đặt cược ngắn hạn, trong khi chuyên gia và tổ chức quốc tế rơi vào trạng thái 'bay mù' và thận trọng, dẫn đến rủi ro hệ thống từ sự thích nghi cảm xúc và điểm gãy cung cầu tại các nút thắt chiến lược.
+
+---
+
+## Trạng thái Thị trường Tương lai: Biến động Cấu trúc và Sự Thích nghi Cảm xúc
+
+Chương này phân tích trạng thái tương lai của thị trường dầu mỏ và tài chính, nơi mà áp lực địa chính trị đã tạo ra một cấu trúc biến động mới. Kết quả mô phỏng cho thấy thị trường sẽ không còn phản ứng tuyến tính với tin tức, mà thay vào đó là sự thích nghi cảm xúc phức tạp giữa các nhóm tác nhân khác nhau.
+
+**Biến động Cấu trúc và Xu hướng Giá Cao**
+
+Trong kịch bản tương lai được mô phỏng, thị trường dầu mỏ đã chuyển dịch từ trạng thái dư thừa sang tình trạng thiếu hụt nghiêm trọng, dẫn đến việc giá dầu duy trì ở mức cao và biến động mạnh. Nguyên nhân cốt lõi là sự phong tỏa Eo biển Hormuz và xung đột kéo dài ở Iran.
+
+> "Giá dầu đã tăng khoảng 40% kể từ khi xung đột Iran bắt đầu, và thị trường sẵn sàng cho những mức tăng thêm nếu tình hình ở Trung Đông xấu đi trở lại."
+
+Sự biến động này không chỉ là tạm thời mà mang tính cấu trúc. Các nhà phân tích trong mô phỏng đã xác định một xu hướng dài hạn mới.
+
+> "Các nhà phân tích tin rằng tình trạng giá cao và nguồn cung thấp trong lĩnh vực dầu mỏ sẽ tiếp tục trong ít nhất sáu tháng."
+
+Điều này được củng cố bởi thực tế là nguồn cung vật lý bị gián đoạn nghiêm trọng.
+
+> "Cơ quan Năng lượng Quốc tế (IEA) cảnh báo rằng trong kịch bản bất lợi hơn của cuộc xung đột kéo dài, thị trường năng lượng và nền kinh tế trên khắp thế giới cần chuẩn bị cho những gián đoạn đáng kể trong những tháng tới."
+
+**Sự Phân hóa Cảm xúc: Nhà Giao dịch Đòn bẩy vs. Chuyên gia Thận trọng**
+
+Một đặc điểm nổi bật của tương lai này là sự phân hóa rõ rệt giữa các nhóm tham gia thị trường. Nhà giao dịch sử dụng đòn bẩy (traders) thường phản ứng cảm xúc với tin tức ngắn hạn, trong khi các chuyên gia và tổ chức quốc tế rơi vào trạng thái thận trọng và thiếu thông tin rõ ràng ("bay mù").
+
+**Nhà giao dịch tận dụng đòn bẩy**
+
+Nhóm này bị ảnh hưởng nặng nề bởi sự biến động nhanh chóng và thường đặt cược sai hướng.
+
+> "Các nhà giao dịch hàng hóa đã chịu thiệt hại hàng tỷ đô la vì họ đã đặt cược sai hướng về giá dầu."
+
+Hành vi của họ thường bị chi phối bởi các tuyên bố từ cấp cao chính trị, dẫn đến những đợt tăng giảm thất thường.
+
+> "Hợp đồng tương lai dầu thô đã nảy khắp nơi, thường dao động dựa trên tuyên bố lạc quan nhất từ Trump về việc chấm dứt xung đột."
+
+Tuy nhiên, sự tham gia của họ cũng tạo ra các cơ hội đầu cơ rủi ro cao.
+
+> "Việc chuyển hướng dòng tiền vào thị trường hàng hóa có thể ảnh hưởng đáng kể đến giá cả, có thể bất lợi cho nhà đầu tư."
+
+**Chuyên gia và Tổ chức Quốc tế**
+
+Ngược lại, các chuyên gia nhận ra rằng tình hình nghiêm trọng hơn nhiều so với những gì thị trường ngắn hạn đang định giá.
+
+> "Nic Dyer lưu ý rằng tình trạng thắt chặt nguồn cung dầu thô nghiêm trọng hơn nhiều so với những gì các nhà giao dịch tương lai có thể giả định."
+
+Tuy nhiên, ngay cả các tổ chức tài chính lớn cũng có xu hướng đánh giá thấp tác động thực sự.
+
+> "Các nhà kinh tế trong hầu hết các tổ chức tài chính đánh giá thấp tác động của việc mất nguồn tài nguyên năng lượng. Khái niệm sai lầm này giải thích tại sao các nhà kinh tế... và do đó nhà đầu tư, không đặc biệt lo lắng."
+
+Sự thiếu hiểu biết này dẫn đến rủi ro hệ thống khi các cú sốc giá lan rộng.
+
+> "Các nhà hoạch định chính sách mô tả kết quả của căng thẳng dai dẳng là冒着 rủi ro 'phá hủy nhu cầu' hoàn toàn."
+
+**Thích nghi Cảm xúc và Rủi ro Hệ thống**
+
+Theo thời gian, thị trường bắt đầu thể hiện dấu hiệu của sự thích nghi cảm xúc. Ban đầu, thị trường phản ứng mạnh với mọi tin tức, nhưng dần dần trở nên mệt mỏi và lọc bỏ các nhiễu loạn.
+
+> "Thị trường mệt mỏi vì chiến tranh lọc bỏ tiếng ồn, theo Morning Bid công bố vào ngày 14 tháng 4 năm 2026."
+
+Tuy nhiên, sự "thích nghi" này không có nghĩa là ổn định, mà là sự chấp nhận rủi ro cao hơn. Nhà đầu tư vẫn duy trì sự bi quan nhưng coi đó là dấu hiệu tích cực theo cách ngược lại.
+
+> "Oliver Pursche phân tích thị trường chứng khoán Mỹ và coi sự bi quan cao của nhà đầu tư là dấu hiệu ngược lại đáng tin cậy."
+
+Kết quả là, thị trường tồn tại trong trạng thái căng thẳng cao độ, nơi mà bất kỳ sự thay đổi nào về đàm phán hòa bình hoặc leo thang quân sự đều có thể kích hoạt các điểm gãy cung cầu.
+
+> "Các nhà phân tích cho thấy sự sụp đổ của các cuộc đàm phán hòa bình là rủi ro tăng giá cho thị trường."
+
+Tóm lại, tương lai của thị trường dầu mỏ và tài chính được định hình bởi sự biến động cấu trúc do địa chính trị, sự phân hóa giữa các tác nhân giao dịch ngắn hạn và các chuyên gia dài hạn, cùng với sự thích nghi cảm xúc của thị trường trước một môi trường rủi ro kéo dài.
+
+## Phản ứng Phân hóa của Các Tác nhân: Từ Đặt cược Đòn bẩy đến Sự Thận trọng Chiến lược
+
+Chương này đi sâu vào sự phân hóa rõ rệt trong cách các tác nhân thị trường phản ứng với cú sốc địa chính trị. Trong khi một nhóm nhà giao dịch mạo hiểm sử dụng đòn bẩy để đặt cược vào các kết quả ngoại giao ngắn hạn, thì các chuyên gia và tổ chức tài chính lớn lại rơi vào trạng thái thận trọng chiến lược, thậm chí là "bay mù" trước thực tế nguồn cung vật lý bị thắt chặt nghiêm trọng.
+
+**Nhà giao dịch đòn bẩy: Đặt cược vào hòa bình và trả giá đắt**
+
+Trong giai đoạn đầu của cuộc xung đột, tâm lý thị trường bị chi phối mạnh mẽ bởi hy vọng về các thỏa thuận ngoại giao. Các nhà giao dịch hàng hóa, đặc biệt là những người sử dụng đòn bẩy cao, đã tập trung vào khả năng đạt được một thỏa thuận hòa bình lâu dài, dẫn đến những vị thế đầu cơ rủi ro.
+
+> "Giá dầu đã nằm dưới mức 100 đô la một thùng, với các nhà giao dịch chú ý đến hy vọng về một thỏa thuận hòa bình lâu dài."
+
+Tuy nhiên, sự lạc quan này đã dẫn đến những tổn thất nặng nề khi thực tế nguồn cung vật lý không thể phục hồi nhanh chóng. Các nhà giao dịch đã thất bại trong việc dự đoán tác động của việc Iran phong tỏa Eo biển Hormuz, một kịch bản trước đó được coi là khó xảy ra.
+
+> "Các nhà giao dịch hàng hóa đã chịu thiệt hại hàng tỷ đô la vì họ đã đặt cược sai hướng về giá dầu."
+
+Dữ liệu giao dịch cho thấy sự đầu cơ mạnh mẽ ngay trước các sự kiện địa chính trị quan trọng.
+
+> "Nhà đầu tư đã đặt cược trị giá khoảng 760 triệu đô la vào việc giá dầu giảm khoảng 20 phút trước khi Iran công bố việc mở lại Eo biển Hormuz."
+
+> "Nhà đầu tư đã đặt cược 950 triệu đô la vào giá dầu chỉ vài giờ trước khi Hoa Kỳ và Iran tuyên bố ngừng bắn."
+
+Những hoạt động giao dịch này đã thu hút sự chú ý của cơ quan quản lý.
+
+> "Ủy ban Giao dịch Hàng hóa Tương lai Hoa Kỳ (CFTC) đang xem xét một loạt các giao dịch trong hợp đồng tương lai dầu mỏ được thực hiện ngay trước những thay đổi lớn trong chính sách chiến tranh Iran của Tổng thống Donald Trump."
+
+**Chuyên gia và Tổ chức: Sự thận trọng chiến lược và "Bay mù"**
+
+Ngược lại với sự đầu cơ ngắn hạn, các chuyên gia phân tích và tổ chức quốc tế nhận ra rằng tình hình nguồn cung nghiêm trọng hơn nhiều so với những gì thị trường tương lai đang định giá. Họ rơi vào trạng thái thận trọng chiến lược, cảnh báo về những rủi ro dài hạn mà các nhà giao dịch ngắn hạn bỏ qua.
+
+> "Tình trạng thắt chặt nguồn cung dầu thô nghiêm trọng hơn nhiều so với những gì các nhà giao dịch tương lai có thể giả định."
+
+Các nhà phân tích từ Wood Mackenzie đã chỉ ra rằng việc phục hồi nguồn cung sẽ mất nhiều thời gian hơn dự kiến, ngay cả khi có lệnh ngừng bắn.
+
+> "Các nhà phân tích của Wood Mackenzie chỉ ra rằng dầu thô sẽ mất hai đến ba tuần để đến châu Âu sau khi lưu lượng tàu thuyền dọc theo Eo biển Hormuz trở lại bình thường."
+
+Sự thiếu hiểu biết về tác động thực sự của việc mất nguồn tài nguyên năng lượng cũng dẫn đến một sự đánh giá thấp rủi ro từ phía các tổ chức tài chính lớn.
+
+> "Các nhà kinh tế trong hầu hết các tổ chức tài chính đánh giá thấp tác động của việc mất nguồn tài nguyên năng lượng. Khái niệm sai lầm này giải thích tại sao các nhà kinh tế... và do đó nhà đầu tư, không đặc biệt lo lắng."
+
+Tuy nhiên, ngay cả các chuyên gia cũng thừa nhận sự bất định cao độ.
+
+> "Tom Kloza cho biết mọi người đang cố gắng đánh giá thị trường dầu mỏ sẽ trông như thế nào trong 'ngày hôm sau', nhưng mọi người đều đang bay mù."
+
+**Tâm lý nhà đầu tư: Thích nghi cảm xúc và tập trung vào cơ bản**
+
+Dưới áp lực của biến động giá, tâm lý nhà đầu tư cá nhân và tổ chức đã trải qua quá trình thích nghi cảm xúc. Ban đầu, thị trường phản ứng mạnh với mọi tuyên bố từ Nhà Trắng, nhưng dần dần trở nên mệt mỏi và tập trung vào các yếu tố cơ bản.
+
+> "Thị trường mệt mỏi vì chiến tranh lọc bỏ tiếng ồn."
+
+Mức độ bi quan cao của nhà đầu tư được một số chuyên gia coi là dấu hiệu ngược lại đáng tin cậy.
+
+> "Oliver Pursche phân tích thị trường chứng khoán Mỹ và coi sự bi quan cao của nhà đầu tư là dấu hiệu ngược lại đáng tin cậy."
+
+Khi giá dầu được định giá ổn định hơn, nhà đầu tư bắt đầu quay trở lại tập trung vào kết quả kinh doanh và dữ liệu kinh tế vĩ mô.
+
+> "Nhà đầu tư đang tập trung lại vào kết quả kinh doanh và các yếu tố cơ bản của nền kinh tế."
+
+Sự phân hóa này cho thấy một thị trường đang trong quá trình chuyển dịch từ phản ứng cảm xúc ngắn hạn sang một trạng thái thận trọng dài hạn, nơi rủi ro hệ thống từ sự thích nghi cảm xúc và điểm gãy cung cầu vẫn là mối đe dọa tiềm tàng.
+
+## Xu hướng Nổi lên và Rủi ro Hệ thống: Bẫy Thông tin và Điểm gãy Cung cầu
+
+Chương này khám phá sự hình thành của một "bẫy thông tin" sâu sắc và điểm gãy cung cầu đang định hình lại cấu trúc thị trường năng lượng trong tương lai. Kết quả mô phỏng cho thấy thị trường không còn phản ứng đơn thuần với tin tức, mà đang đối mặt với một nghịch lý hệ thống: sự lạc quan ngoại giao trên bề mặt đang che giấu một thực tế nguồn cung vật lý bị tổn thương nghiêm trọng và khó phục hồi.
+
+**Bẫy Thông tin: Sự Mâu thuẫn giữa Hy vọng Ngoại giao và Thực tế Vật lý**
+
+Trong môi trường tương lai được mô phỏng, thị trường dầu mỏ rơi vào trạng thái phân cực thông tin nghiêm trọng. Một mặt, các tuyên bố từ cấp cao chính trị, đặc biệt là từ Nhà Trắng, liên tục tạo ra những đợt hy vọng về hòa bình. Mặt khác, dữ liệu hậu cần và dòng chảy vật lý cho thấy một bức tranh u ám hơn nhiều.
+
+> "Giá dầu tăng nhẹ khoảng 1% khi thị trường tập trung nhiều hơn vào gián đoạn nguồn cung và hạn chế vận chuyển hơn là các bình luận của Donald Trump rằng cuộc chiến với Iran sắp kết thúc."
+
+Sự phân hóa này tạo ra một bẫy thông tin, nơi các nhà giao dịch ngắn hạn dễ dàng bị cuốn theo các tín hiệu ngoại giao, trong khi bỏ qua các rủi ro cấu trúc.
+
+> "Thị trường diễn giải lệnh phong tỏa Eo biển Hormuz của Tổng thống Trump là một chiến thuật đàm phán nhằm cắt đứt doanh thu dầu mỏ của Iran, chứ không phải là một sự leo thang thực sự."
+
+Tuy nhiên, theo thời gian, thị trường bắt đầu thể hiện dấu hiệu của sự mệt mỏi và thích nghi.
+
+> "Morning Bid thảo luận về cách các thị trường mệt mỏi vì chiến tranh lọc bỏ tiếng ồn."
+
+Điều này cho thấy một sự dịch chuyển tâm lý: nhà đầu tư dần nhận ra rằng các tuyên bố chính trị không thể thay đổi ngay lập tức thực tế của các đường ống bị phá hủy và tàu thuyền bị mắc kẹt.
+
+**Điểm gãy Cung cầu: Sự Phục hồi Không đồng đều và "Phụ phí Dư thừa"**
+
+Ngay cả trong kịch bản lạc quan nhất khi lệnh ngừng bắn được ký kết và Eo biển Hormuz mở cửa trở lại, mô phỏng cho thấy nguồn cung dầu mỏ sẽ không thể phục hồi nhanh chóng. Đây chính là "điểm gãy cung cầu" mới, nơi mà sự thiếu hụt không chỉ đến từ xung đột, mà còn từ sự suy yếu của toàn bộ chuỗi cung ứng hạ tầng.
+
+Các chuyên gia trong mô phỏng đã chỉ ra những rào cản vật lý khổng lồ:
+
+> "Karan Satwani nhận thấy tình trạng thiếu hụt thiết bị sẵn có và nhân công chuyên biệt sẵn sàng triển khai đến các cơ sở hạ tầng năng lượng bị hư hỏng, bất kể cuộc chiến kết thúc vào khi nào."
+
+Hơn nữa, hậu quả của việc phong tỏa tạo ra một nút thắt logistics khó giải quyết.
+
+> "Joe DeLaura cho biết về việc mất sản lượng vĩnh viễn từ các giếng dầu bị đóng cửa ở Saudi, Kuwait, UAE và Iraq, thiệt hại nhà máy lọc dầu và đường ống, cùng thời gian khởi động lại vật lý, bên cạnh hàng đợi hơn 800 tàu chở dầu bị mắc kẹt ở phía tây Eo biển."
+
+Kết quả là, thị trường sẽ không còn định giá theo kịch bản "ngừng cung hoàn toàn", nhưng cũng không thể trở lại trạng thái bình thường cũ.
+
+> "Các nhà phân tích của Gelber nhận định kết quả là một thị trường không còn định giá một sự gián đoạn quy mô lớn, nhưng vẫn duy trì một phụ phí dư thừa khi dòng chảy phục hồi không đồng đều thay vì bật trở lại bình thường tại Eo biển Hormuz."
+
+**Rủi ro Hệ thống: Giao dịch Nội bộ và Sự can thiệp của Cơ quan Quản lý**
+
+Sự chênh lệch thông tin giữa các nhà hoạch định chính sách và thị trường giao dịch đã kích hoạt các rủi ro hệ thống về đạo đức và tuân thủ. Mô phỏng cho thấy sự xuất hiện của các giao dịch "đúng thời điểm" đáng ngờ, dẫn đến sự can thiệp mạnh mẽ từ các cơ quan quản lý.
+
+> "Ủy ban Giao dịch Hàng hóa Tương lai Hoa Kỳ (CFTC) đang xem xét một loạt các giao dịch trong hợp đồng tương lai dầu mỏ được thực hiện ngay trước những thay đổi lớn trong chính sách chiến tranh Iran của Tổng thống Donald Trump."
+
+Điều này cho thấy thông tin địa chính trị nhạy cảm đang bị lợi dụng, tạo ra một lớp rủi ro pháp lý mới cho thị trường tài chính.
+
+> "Dữ liệu được yêu cầu từ các sàn giao dịch về giao dịch đúng thời điểm trong thị trường dầu mỏ bao gồm các định danh Tag 50 của các thực thể đứng sau các giao dịch."
+
+Sự can thiệp này không chỉ nhằm trừng phạt cá nhân, mà còn là nỗ lực để khôi phục niềm tin vào tính minh bạch của thị trường khi nó đang bị xói mòn bởi "bẫy thông tin" và sự không chắc chắn địa chính trị.
+
+**Xu hướng Giá và Rủi ro Ngầm**
+
+Tổng hợp lại, xu hướng giá trong tương lai này sẽ không giảm sâu ngay cả khi có tin tức hòa bình. Sự phục hồi của giá sẽ được hỗ trợ bởi "phụ phí dư thừa" và thực tế nguồn cung bị thắt chặt dài hạn.
+
+> "Các nhà phân tích cho biết rủi ro tăng giá chính của thị trường là sự sụp đổ của các cuộc đàm phán hòa bình giữa Hoa Kỳ và Iran, khi các yêu cầu của hai bên vẫn còn cách xa nhau."
+
+Thị trường đang bước vào giai đoạn mới, nơi mà giá dầu không chỉ phản ánh cung cầu tức thời, mà còn là thước đo của sự phục hồi hạ tầng và độ tin cậy của thông tin địa chính trị.
+
diff --git a/test_code_backend/full_pipeline/README.md b/test_code_backend/full_pipeline/README.md
new file mode 100644
index 00000000..e9894299
--- /dev/null
+++ b/test_code_backend/full_pipeline/README.md
@@ -0,0 +1,262 @@
+# Full Pipeline Runner
+
+Chạy toàn bộ MiroFish pipeline từ command line, không cần mở UI.
+
+## Files
+
+| File | Vai trò |
+|---|---|
+| `prepare_input.py` | Đọc config, load & filter articles, ghi combined text ra temp file, in JSON metadata ra stdout |
+| `run.sh` | Gọi `prepare_input.py`, khởi động backend nếu cần, rồi gọi tuần tự các API |
+| `config_articles.env` | Cấu hình input |
+
+
+## Cấu hình (`config_articles.env`)
+
+| Tham số | Bắt buộc | Mô tả |
+|---|---|---|
+| `article_path` | Có | Đường dẫn thư mục chứa `articles.jsonl` và thư mục `news/` |
+| `full_content` | Không | `True` = giữ toàn bộ markdown; `False` (mặc định) = chỉ lấy body bài viết, bỏ YAML frontmatter |
+| `start_time` | Không | Lọc bài viết từ ngày (`YYYY-MM-DD`), để trống = không lọc |
+| `end_time` | Không | Lọc bài viết đến ngày (`YYYY-MM-DD`), để trống = không lọc |
+| `simulation_requirement` | Không | Mô tả yêu cầu phân tích/dự báo gửi cho LLM |
+| `project_name` | Không | Tên project |
+
+---
+
+## Cách 1 — Chạy toàn bộ pipeline 1 lệnh (khuyến nghị)
+
+**Không cần mở terminal riêng cho backend.** `run.sh` tự kiểm tra và khởi động Flask nếu chưa chạy.
+
+Chạy 7 bước tự động: ontology → graph → create sim → prepare sim → start sim → chờ hoàn thành → generate report.
+
+```bash
+# Từ project root
+cd /home/anman/intern/MiroFish
+bash test_code_backend/full_pipeline/run.sh
+
+# Hoặc chỉ định config khác
+bash test_code_backend/full_pipeline/run.sh path/to/config.env
+```
+
+Log in ra terminal từng bước. Cuối cùng in ra `project_id`, `graph_id`, `simulation_id`, `report_id`.
+
+> Backend được tự động khởi động trong background — **không cần mở terminal riêng**. Log đầy đủ nằm ở `backend/logs/YYYY-MM-DD.log`.
+
+Muốn kill port
+```bash
+kill $(lsof -ti :5001)
+```
+---
+
+## Cách 2 — Chạy từng bước thủ công
+
+Khi cần debug hoặc muốn kiểm tra kết quả từng bước.
+
+### Bước 0: Khởi động Flask backend (port 5001)
+
+Mở **terminal riêng**, giữ nguyên trong suốt quá trình:
+
+```bash
+cd /home/anman/intern/MiroFish
+FLASK_PORT=5001 npm run backend
+# hoặc: python backend/run.py
+```
+
+Kiểm tra backend đã sẵn sàng:
+```bash
+curl http://localhost:5001/health
+# → {"status": "ok", "service": "MiroFish Backend"}
+```
+
+### Bước 1: Chuẩn bị input (Python)
+
+Chạy trong **terminal khác**:
+
+```bash
+cd /home/anman/intern/MiroFish
+python3 test_code_backend/full_pipeline/prepare_input.py \
+ test_code_backend/full_pipeline/config_articles.env
+```
+
+Output là JSON, ví dụ:
+```json
+{
+ "combined_file": "/tmp/mirofish_articles_abc123.md",
+ "article_count": 42,
+ "project_name": "Oil Market Sentiment Pipeline",
+ "simulation_requirement": "Analyze how ...",
+ "start_time": null,
+ "end_time": null
+}
+```
+
+Lưu lại đường dẫn `combined_file` để dùng ở bước tiếp theo.
+
+---
+
+### Bước 2: Generate ontology → lấy `project_id`
+
+```bash
+COMBINED_FILE="/home/anman/intern/MiroFish/test_code_backend/full_pipeline/data/articles_combined.md"
+
+curl -s http://localhost:5001/api/graph/ontology/generate \
+ -F "files=@${COMBINED_FILE};filename=articles.md" \
+ -F "simulation_requirement=Analyze how these oil and financial market news articles affect investor sentiment and market dynamics. Predict how different market participants (traders, analysts, retail investors) will react and what the overall price trend will be." \
+ -F "project_name=Oil Market Pipeline" \
+ | jq '{project_id: .data.project_id, entity_types: [.data.ontology.entity_types[].name]}'
+```
+
+Lưu lại `project_id`.
+
+---
+
+### Bước 3: Build knowledge graph → lấy `task_id`
+
+```bash
+PROJECT_ID="proj_xxxxxxxxxxxx"
+
+curl -s http://localhost:5001/api/graph/build \
+ -H "Content-Type: application/json" \
+ -d "{\"project_id\": \"$PROJECT_ID\"}" \
+ | jq '{task_id: .data.task_id}'
+```
+
+Poll tiến độ build (chạy lặp lại đến khi `status=completed`):
+
+```bash
+TASK_ID="task_xxxxxxxxxxxx"
+
+curl -s http://localhost:5001/api/graph/task/$TASK_ID \
+ | jq '{status: .data.status, progress: .data.progress, graph_id: .data.result.graph_id}'
+```
+
+Khi `status=completed`, lưu lại `graph_id`.
+
+---
+
+### Bước 4: Tạo simulation → lấy `simulation_id`
+
+```bash
+PROJECT_ID="proj_xxxxxxxxxxxx"
+
+curl -s http://localhost:5001/api/simulation/create \
+ -H "Content-Type: application/json" \
+ -d "{\"project_id\": \"$PROJECT_ID\", \"enable_twitter\": true, \"enable_reddit\": true}" \
+ | jq '{simulation_id: .data.simulation_id}'
+```
+
+---
+
+### Bước 5: Prepare simulation (sinh agent profiles + config)
+
+```bash
+SIM_ID="sim_xxxxxxxxxxxx"
+
+curl -s http://localhost:5001/api/simulation/prepare \
+ -H "Content-Type: application/json" \
+ -d "{\"simulation_id\": \"$SIM_ID\", \"use_llm_for_profiles\": true, \"parallel_profile_count\": 5}" \
+ | jq '{task_id: .data.task_id, status: .data.status}'
+```
+
+Poll tiến độ (bước này lâu nhất, ~5–15 phút tùy số agents):
+
+```bash
+TASK_ID="task_xxxxxxxxxxxx"
+
+curl -s http://localhost:5001/api/simulation/prepare/status \
+ -H "Content-Type: application/json" \
+ -d "{\"task_id\": \"$TASK_ID\", \"simulation_id\": \"$SIM_ID\"}" \
+ | jq '{status: .data.status, progress: .data.progress, message: .data.message}'
+```
+
+---
+
+### Bước 6: Start simulation
+
+```bash
+SIM_ID="sim_xxxxxxxxxxxx"
+
+curl -s http://localhost:5001/api/simulation/start \
+ -H "Content-Type: application/json" \
+ -d "{\"simulation_id\": \"$SIM_ID\", \"platform\": \"parallel\"}" \
+ | jq '{runner_status: .data.runner_status, total_rounds: .data.total_rounds}'
+```
+
+---
+
+### Bước 7: Theo dõi simulation đến khi hoàn thành
+
+Chạy lệnh sau để tự động poll mỗi 30 giây, tự thoát khi simulation kết thúc:
+
+```bash
+SIM_ID="sim_xxxxxxxxxxxx"
+
+while true; do
+ RESP=$(curl -s http://localhost:5001/api/simulation/$SIM_ID/run-status)
+ RS=$(echo "$RESP" | jq -r '.data.runner_status')
+ CR=$(echo "$RESP" | jq -r '.data.current_round')
+ TR=$(echo "$RESP" | jq -r '.data.total_rounds')
+ echo "[$(date '+%H:%M:%S')] $RS round=$CR/$TR"
+ [[ "$RS" == "completed" || "$RS" == "stopped" || "$RS" == "failed" ]] && break
+ sleep 30
+done
+```
+
+Hoặc kiểm tra thủ công một lần:
+
+```bash
+# Trạng thái tổng quan (round hiện tại, % tiến độ)
+curl -s http://localhost:5001/api/simulation/$SIM_ID/run-status \
+ | jq '{runner_status: .data.runner_status, current_round: .data.current_round, total_rounds: .data.total_rounds}'
+
+# Hành động của agents (real-time)
+curl -s "http://localhost:5001/api/simulation/$SIM_ID/actions?limit=20" | jq .
+```
+
+---
+
+### Bước 8: Generate report
+
+```bash
+SIM_ID="sim_xxxxxxxxxxxx"
+
+SIM_ID="sim_07ab325b3818"
+# 1. Bắt đầu generate → lấy task_id và report_id
+RESP=$(curl -s http://localhost:5001/api/report/generate \
+ -H "Content-Type: application/json" \
+ -d "{\"simulation_id\": \"$SIM_ID\"}")
+TASK_ID=$(echo "$RESP" | jq -r '.data.task_id')
+REPORT_ID=$(echo "$RESP" | jq -r '.data.report_id')
+echo "task_id=$TASK_ID report_id=$REPORT_ID"
+
+# 2. Poll đến khi completed (chạy lặp lại, ~5–15 phút)
+while true; do
+ R=$(curl -s http://localhost:5001/api/report/generate/status \
+ -H "Content-Type: application/json" \
+ -d "{\"task_id\": \"$TASK_ID\"}")
+ ST=$(echo "$R" | jq -r '.data.status')
+ PG=$(echo "$R" | jq -r '.data.progress')
+ echo "$ST ${PG}%"
+ [[ "$ST" == "completed" ]] && break
+ [[ "$ST" == "failed" ]] && { echo "FAILED"; break; }
+ sleep 15
+done
+
+# 3. Download file Markdown
+curl -s http://localhost:5001/api/report/$REPORT_ID/download -o report.md
+echo "Saved to report.md"
+```
+
+---
+
+## Output files
+
+| Loại | Đường dẫn |
+|---|---|
+| Project metadata + extracted text | `backend/uploads/projects//` |
+| Agent profiles (Reddit) | `backend/uploads/simulations//reddit_profiles.json` |
+| Agent profiles (Twitter) | `backend/uploads/simulations//twitter_profiles.csv` |
+| Simulation config (LLM-generated) | `backend/uploads/simulations//simulation_config.json` |
+| Run state & action log | `backend/uploads/simulations//run_state.json` |
+| Report | `backend/uploads/reports//` |
diff --git a/test_code_backend/full_pipeline/config_articles.env b/test_code_backend/full_pipeline/config_articles.env
new file mode 100644
index 00000000..30cd28cc
--- /dev/null
+++ b/test_code_backend/full_pipeline/config_articles.env
@@ -0,0 +1,20 @@
+# ─── Article input ────────────────────────────────────────────────────────────
+# Path to the folder containing articles.jsonl (and the news/ sub-folder)
+article_path = "/home/anman/intern/MiroFish/data"
+
+# If True → use full markdown (frontmatter + body)
+# If False → strip YAML frontmatter, keep article body only
+full_content = False
+
+# Date filter (YYYY-MM-DD). Leave blank to load all dates.
+# start_time = 2026-02-09
+# end_time = 2026-02-12
+start_time = 2026-04-13
+end_time = 2026-04-16
+
+# ─── Simulation settings ──────────────────────────────────────────────────────
+# What the simulation should analyse / predict (sent to the LLM)
+simulation_requirement = "Analyze how these oil and financial market news articles affect investor sentiment and market dynamics. Predict how different market participants (traders, analysts, retail investors) will react and what the overall price trend will be."
+
+# Display name for the project (optional)
+project_name = "Oil Market Sentiment Pipeline"
diff --git a/test_code_backend/full_pipeline/prepare_input.py b/test_code_backend/full_pipeline/prepare_input.py
new file mode 100644
index 00000000..f626cc39
--- /dev/null
+++ b/test_code_backend/full_pipeline/prepare_input.py
@@ -0,0 +1,152 @@
+#!/usr/bin/env python3
+"""
+Prepare article input for MiroFish pipeline.
+
+Reads config_articles.env, loads and filters articles from articles.jsonl,
+combines their text, and writes the result to a temp file.
+
+Outputs a single JSON to stdout:
+ {
+ "combined_file": "/home/anman/intern/MiroFish/test_code_backend/full_pipeline/data/articles_combined.md",
+ "article_count": 42,
+ "project_name": "...",
+ "simulation_requirement": "...",
+ "start_time": "2025-11-01", # or null
+ "end_time": "2025-11-30" # or null
+ }
+
+Usage:
+ python prepare_input.py [config_articles.env]
+"""
+
+import json
+import os
+import sys
+from datetime import datetime
+from pathlib import Path
+
+
+# ─── Config parser ────────────────────────────────────────────────────────────
+path_output = "/home/anman/intern/MiroFish/test_code_backend/full_pipeline/data"
+
+def parse_config(path: str) -> dict:
+ """Parse 'key = value' config file (spaces around = allowed, # = comment).
+ Values may optionally be wrapped in single or double quotes."""
+ cfg = {}
+ with open(path, encoding="utf-8") as f:
+ for line in f:
+ line = line.strip()
+ if not line or line.startswith("#") or "=" not in line:
+ continue
+ key, _, value = line.partition("=")
+ key = key.strip()
+ value = value.strip().strip('"').strip("'")
+ if value:
+ cfg[key] = value
+ return cfg
+
+
+# ─── Article loading ──────────────────────────────────────────────────────────
+
+def strip_frontmatter(content: str) -> str:
+ """Remove YAML frontmatter (--- … ---), return article body only."""
+ if content.startswith("---"):
+ end = content.find("---", 3)
+ if end != -1:
+ return content[end + 3:].lstrip("\n")
+ return content
+
+
+def load_articles(article_path: str, full_content: bool,
+ start_time: str, end_time: str) -> list:
+ jsonl_path = os.path.join(article_path, "articles.jsonl")
+ if not os.path.exists(jsonl_path):
+ raise FileNotFoundError(f"articles.jsonl not found: {jsonl_path}")
+
+ start_dt = datetime.strptime(start_time, "%Y-%m-%d").date() if start_time else None
+ end_dt = datetime.strptime(end_time, "%Y-%m-%d").date() if end_time else None
+
+ articles = []
+ with open(jsonl_path, encoding="utf-8") as f:
+ for line in f:
+ line = line.strip()
+ if not line:
+ continue
+ record = json.loads(line)
+ date_str = record.get("published_date", "")
+ file_rel = record.get("file_path", "")
+ if not date_str or not file_rel:
+ continue
+
+ try:
+ date = datetime.strptime(date_str, "%Y-%m-%d").date()
+ except ValueError:
+ continue
+
+ if start_dt and date < start_dt:
+ continue
+ if end_dt and date > end_dt:
+ continue
+
+ full_path = os.path.join(article_path, file_rel)
+ if not os.path.exists(full_path):
+ continue
+
+ with open(full_path, encoding="utf-8") as mdf:
+ raw = mdf.read()
+
+ content = raw if full_content else strip_frontmatter(raw)
+ articles.append({"date": date_str, "file_path": file_rel, "content": content})
+
+ articles.sort(key=lambda a: a["date"])
+ return articles
+
+
+# ─── Main ─────────────────────────────────────────────────────────────────────
+
+def main():
+ script_dir = Path(__file__).parent
+ config_path = sys.argv[1] if len(sys.argv) > 1 else str(script_dir / "config_articles.env")
+
+ cfg = parse_config(config_path)
+
+ article_path = cfg.get("article_path", "")
+ full_content = cfg.get("full_content", "False").strip().lower() in ("true", "1", "yes")
+ start_time = cfg.get("start_time") or ""
+ end_time = cfg.get("end_time") or ""
+ sim_req = cfg.get(
+ "simulation_requirement",
+ "Analyze how these news articles affect market sentiment and predict participant behavior.",
+ )
+ project_name = cfg.get("project_name", "MiroFish Pipeline")
+
+ if not article_path:
+ print(json.dumps({"error": "article_path is required"}))
+ sys.exit(1)
+
+ articles = load_articles(article_path, full_content, start_time, end_time)
+ if not articles:
+ print(json.dumps({"error": "No articles found for the given parameters"}))
+ sys.exit(1)
+
+ # Write combined text to path_output
+ os.makedirs(path_output, exist_ok=True)
+ out_path = os.path.join(path_output, "articles_combined.md")
+ with open(out_path, "w", encoding="utf-8") as f:
+ for art in articles:
+ f.write(f"\n\n=== {art['file_path']} ({art['date']}) ===\n\n")
+ f.write(art["content"])
+
+ print(json.dumps({
+ "combined_file": out_path,
+ "article_count": len(articles),
+ "project_name": project_name,
+ "simulation_requirement": sim_req,
+ "start_time": start_time or None,
+ "end_time": end_time or None,
+ "word_count": sum(len(art["content"].split()) for art in articles)
+ }, ensure_ascii=False))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/test_code_backend/full_pipeline/run.sh b/test_code_backend/full_pipeline/run.sh
new file mode 100644
index 00000000..d6b7ec28
--- /dev/null
+++ b/test_code_backend/full_pipeline/run.sh
@@ -0,0 +1,217 @@
+#!/usr/bin/env bash
+# MiroFish full pipeline runner — calls prepare_input.py then MiroFish API.
+#
+# Usage:
+# bash run.sh
+# bash run.sh path/to/config.env
+#
+# Requirements: curl, jq, python3
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
+BACKEND_DIR="$PROJECT_ROOT/backend"
+CONFIG_FILE="${1:-$SCRIPT_DIR/config_articles.env}"
+BACKEND_URL="http://localhost:5001"
+
+info() { echo "[$(date '+%H:%M:%S')] INFO $*"; }
+err() { echo "[$(date '+%H:%M:%S')] ERROR $*" >&2; }
+die() { err "$*"; exit 1; }
+
+command -v curl >/dev/null 2>&1 || die "curl is required"
+command -v jq >/dev/null 2>&1 || die "jq is required (apt install jq)"
+command -v python3 >/dev/null 2>&1 || die "python3 is required"
+
+[[ -f "$CONFIG_FILE" ]] || die "Config file not found: $CONFIG_FILE"
+
+# ─── Step 0: Prepare input (Python) ──────────────────────────────────────────
+info "=== Step 0: Preparing article input ==="
+INPUT_JSON=$(python3 "$SCRIPT_DIR/prepare_input.py" "$CONFIG_FILE") \
+ || die "prepare_input.py failed"
+
+# Check for error from prepare_input.py
+if echo "$INPUT_JSON" | jq -e '.error' >/dev/null 2>&1; then
+ die "Input error: $(echo "$INPUT_JSON" | jq -r '.error')"
+fi
+
+COMBINED_FILE=$(echo "$INPUT_JSON" | jq -r '.combined_file')
+ARTICLE_COUNT=$(echo "$INPUT_JSON" | jq -r '.article_count')
+PROJECT_NAME=$( echo "$INPUT_JSON" | jq -r '.project_name')
+SIM_REQ=$( echo "$INPUT_JSON" | jq -r '.simulation_requirement')
+
+trap 'rm -f "$COMBINED_FILE"' EXIT # cleanup temp file on exit
+
+info " articles : $ARTICLE_COUNT"
+info " project_name : $PROJECT_NAME"
+info " combined_file : $COMBINED_FILE ($(wc -c < "$COMBINED_FILE" | tr -d ' ') bytes)"
+
+# ─── Start Flask backend (if not already running) ─────────────────────────────
+if curl -sf "$BACKEND_URL/health" >/dev/null 2>&1; then
+ info "Backend already running at $BACKEND_URL"
+else
+ info "Starting Flask backend..."
+ VENV="$BACKEND_DIR/.venv"
+ PYTHON=$( [[ -f "$VENV/bin/python" ]] && echo "$VENV/bin/python" || echo "python3" )
+
+ "$PYTHON" "$BACKEND_DIR/run.py" >/dev/null 2>&1 &
+
+ info "Waiting for backend to be ready..."
+ for i in $(seq 1 30); do
+ sleep 2
+ curl -sf "$BACKEND_URL/health" >/dev/null 2>&1 && { info "Backend ready"; break; }
+ [[ $i -eq 30 ]] && die "Backend did not start (check backend/logs/)"
+ done
+fi
+
+# ─── Helper: assert API response has success=true ─────────────────────────────
+ok() { echo "$1" | jq -r '.success // false'; }
+assert_ok() { [[ "$(ok "$1")" == "true" ]] || { err "Step '$2' failed:"; echo "$1" | jq . >&2; die "Aborted"; }; }
+
+# ─── Helper: poll GET task until completed/failed ─────────────────────────────
+poll_task() {
+ local url="$1" label="$2"
+ while true; do
+ local body; body=$(curl -sf "$url") || { sleep 10; continue; }
+ local st msg
+ st=$(echo "$body" | jq -r '.data.status // empty')
+ pg=$(echo "$body" | jq -r '.data.progress // 0')
+ msg=$(echo "$body"| jq -r '.data.message // empty')
+ info " [$label] $st ${pg}% — $msg"
+ [[ "$st" == "completed" ]] && return 0
+ [[ "$st" == "failed" ]] && { err "$label failed: $msg"; die "Aborted"; }
+ sleep 10
+ done
+}
+
+# ─── Step 1: Generate ontology ────────────────────────────────────────────────
+info "=== Step 1/7: Generating ontology ==="
+RESP=$(curl -sf "$BACKEND_URL/api/graph/ontology/generate" \
+ -F "files=@${COMBINED_FILE};filename=articles.md" \
+ -F "simulation_requirement=${SIM_REQ}" \
+ -F "project_name=${PROJECT_NAME}")
+assert_ok "$RESP" "ontology/generate"
+
+PROJECT_ID=$(echo "$RESP" | jq -r '.data.project_id')
+info " project_id : $PROJECT_ID"
+
+# ─── Step 2: Build knowledge graph ────────────────────────────────────────────
+info "=== Step 2/7: Building Zep knowledge graph ==="
+RESP=$(curl -sf "$BACKEND_URL/api/graph/build" \
+ -H "Content-Type: application/json" \
+ -d "$(jq -n --arg p "$PROJECT_ID" --arg n "$PROJECT_NAME" \
+ '{project_id: $p, graph_name: $n}')")
+assert_ok "$RESP" "graph/build"
+
+TASK_ID=$(echo "$RESP" | jq -r '.data.task_id')
+info " task_id : $TASK_ID"
+poll_task "$BACKEND_URL/api/graph/task/$TASK_ID" "graph/build"
+
+GRAPH_ID=$(curl -sf "$BACKEND_URL/api/graph/task/$TASK_ID" | jq -r '.data.result.graph_id')
+info " graph_id : $GRAPH_ID"
+
+# ─── Step 3: Create simulation ────────────────────────────────────────────────
+info "=== Step 3/7: Creating simulation ==="
+RESP=$(curl -sf "$BACKEND_URL/api/simulation/create" \
+ -H "Content-Type: application/json" \
+ -d "$(jq -n --arg p "$PROJECT_ID" \
+ '{project_id: $p, enable_twitter: true, enable_reddit: true}')")
+assert_ok "$RESP" "simulation/create"
+
+SIM_ID=$(echo "$RESP" | jq -r '.data.simulation_id')
+info " simulation_id : $SIM_ID"
+
+# ─── Step 4: Prepare simulation ───────────────────────────────────────────────
+info "=== Step 4/7: Preparing simulation (agent profiles + config) ==="
+RESP=$(curl -sf "$BACKEND_URL/api/simulation/prepare" \
+ -H "Content-Type: application/json" \
+ -d "$(jq -n --arg s "$SIM_ID" \
+ '{simulation_id: $s, use_llm_for_profiles: true, parallel_profile_count: 5}')")
+assert_ok "$RESP" "simulation/prepare"
+
+if [[ "$(echo "$RESP" | jq -r '.data.already_prepared')" == "true" ]]; then
+ info " Already prepared, skipping poll"
+else
+ TASK_ID=$(echo "$RESP" | jq -r '.data.task_id')
+ info " task_id : $TASK_ID"
+
+ while true; do
+ RESP=$(curl -sf "$BACKEND_URL/api/simulation/prepare/status" \
+ -H "Content-Type: application/json" \
+ -d "$(jq -n --arg t "$TASK_ID" --arg s "$SIM_ID" \
+ '{task_id: $t, simulation_id: $s}')")
+ st=$(echo "$RESP" | jq -r '.data.status // empty')
+ pg=$(echo "$RESP" | jq -r '.data.progress // 0')
+ msg=$(echo "$RESP" | jq -r '.data.message // empty')
+ info " [prepare] $st ${pg}% — $msg"
+ [[ "$st" == "completed" || "$st" == "ready" ]] && break
+ [[ "$st" == "failed" ]] && { err "Prepare failed: $msg"; die "Aborted"; }
+ sleep 15
+ done
+fi
+
+# ─── Step 5: Start simulation ─────────────────────────────────────────────────
+info "=== Step 5/7: Starting simulation (platform: parallel) ==="
+RESP=$(curl -sf "$BACKEND_URL/api/simulation/start" \
+ -H "Content-Type: application/json" \
+ -d "$(jq -n --arg s "$SIM_ID" '{simulation_id: $s, platform: "parallel"}')")
+assert_ok "$RESP" "simulation/start"
+
+RUNNER_STATUS=$(echo "$RESP" | jq -r '.data.runner_status')
+TOTAL_ROUNDS=$(echo "$RESP" | jq -r '.data.total_rounds')
+info " runner_status : $RUNNER_STATUS"
+info " total_rounds : $TOTAL_ROUNDS"
+
+# ─── Step 6: Wait for simulation to finish ──────────────────────────────────
+info "=== Step 6/7: Waiting for simulation to finish ==="
+info " (monitoring $SIM_ID — Ctrl-C to detach and let it run in background)"
+while true; do
+ RESP=$(curl -sf "$BACKEND_URL/api/simulation/$SIM_ID/run-status") || { sleep 15; continue; }
+ RS=$(echo "$RESP" | jq -r '.data.runner_status // empty')
+ CR=$(echo "$RESP" | jq -r '.data.current_round // 0')
+ TR=$(echo "$RESP" | jq -r '.data.total_rounds // 0')
+ info " runner_status=$RS round=$CR/$TR"
+ [[ "$RS" == "completed" || "$RS" == "stopped" ]] && break
+ [[ "$RS" == "failed" ]] && { err "Simulation failed"; die "Aborted"; }
+ sleep 30
+done
+
+# ─── Step 7: Generate report ─────────────────────────────────────────────────
+info "=== Step 7/7: Generating report ==="
+RESP=$(curl -sf "$BACKEND_URL/api/report/generate" \
+ -H "Content-Type: application/json" \
+ -d "$(jq -n --arg s "$SIM_ID" '{simulation_id: $s}')")
+assert_ok "$RESP" "report/generate"
+
+REPORT_ID=$(echo "$RESP" | jq -r '.data.report_id')
+info " report_id : $REPORT_ID"
+
+if [[ "$(echo "$RESP" | jq -r '.data.already_generated')" == "true" ]]; then
+ info " Report already exists, skipping poll"
+else
+ TASK_ID=$(echo "$RESP" | jq -r '.data.task_id')
+ info " task_id : $TASK_ID"
+ while true; do
+ RESP=$(curl -sf "$BACKEND_URL/api/report/generate/status" \
+ -H "Content-Type: application/json" \
+ -d "$(jq -n --arg t "$TASK_ID" '{task_id: $t}')") || { sleep 10; continue; }
+ st=$(echo "$RESP" | jq -r '.data.status // empty')
+ pg=$(echo "$RESP" | jq -r '.data.progress // 0')
+ msg=$(echo "$RESP" | jq -r '.data.message // empty')
+ info " [report] $st ${pg}% — $msg"
+ [[ "$st" == "completed" ]] && break
+ [[ "$st" == "failed" ]] && { err "Report failed: $msg"; die "Aborted"; }
+ sleep 15
+ done
+fi
+
+# ─── Summary ──────────────────────────────────────────────────────────────────
+echo ""
+info "============================================================"
+info "Pipeline complete"
+info " project_id : $PROJECT_ID"
+info " graph_id : $GRAPH_ID"
+info " simulation_id : $SIM_ID"
+info " report_id : $REPORT_ID"
+info "============================================================"
+info "Download report: curl $BACKEND_URL/api/report/$REPORT_ID/download -o report.md"
diff --git a/test_code_backend/gen_ontogogy_and_graph/build_graph.py b/test_code_backend/gen_ontogogy_and_graph/build_graph.py
new file mode 100644
index 00000000..9d914b16
--- /dev/null
+++ b/test_code_backend/gen_ontogogy_and_graph/build_graph.py
@@ -0,0 +1,197 @@
+"""
+Test: Knowledge Graph build on Zep Cloud
+Input : - output/ontology/ontology_*.json (latest, or pass path as arg)
+ - news articles (same date range as ontology)
+Output: output/graph/
+ graph_.json — build summary (node/edge count, timing)
+ entities_.json — entity detail: raw nodes + filtered result
+
+Run:
+ python build_graph.py # auto-picks latest ontology
+ python build_graph.py output/ontology/ontology_xyz.json # specific ontology file
+"""
+
+import sys
+import json
+import time
+import argparse
+from datetime import datetime, date
+from pathlib import Path
+
+# ── Path setup ───────────────────────────────────────────────────────────────
+SCRIPT_DIR = Path(__file__).parent
+PROJECT_ROOT = SCRIPT_DIR.parent.parent
+BACKEND_DIR = PROJECT_ROOT / "backend"
+ONTOLOGY_DIR = SCRIPT_DIR.parent / "output" / "ontology"
+GRAPH_DIR = SCRIPT_DIR.parent / "output" / "graph"
+NEWS_DIR = PROJECT_ROOT / "data" / "news" / "2026-04"
+
+sys.path.insert(0, str(BACKEND_DIR))
+GRAPH_DIR.mkdir(parents=True, exist_ok=True)
+
+# ── Config ───────────────────────────────────────────────────────────────────
+START_DATE = date(2026, 4, 20)
+END_DATE = date(2026, 4, 22)
+GRAPH_NAME = "MiroFish_Test_Apr2026"
+CHUNK_SIZE = 500
+CHUNK_OVERLAP = 50
+BATCH_SIZE = 3
+POLL_TIMEOUT = 600
+
+
+# ── Helpers ──────────────────────────────────────────────────────────────────
+def log(msg: str):
+ print(f"[{datetime.now().strftime('%H:%M:%S')}] {msg}")
+
+
+def extract_body(md_text: str) -> str:
+ s = md_text.strip()
+ if s.startswith("---"):
+ end = s.find("---", 3)
+ if end != -1:
+ return s[end + 3:].strip()
+ return s
+
+
+def load_combined_text(news_dir: Path, start: date, end: date) -> str:
+ parts = []
+ for md_file in sorted(news_dir.glob("*.md")):
+ try:
+ file_date = date.fromisoformat(md_file.stem.split("_")[0])
+ except ValueError:
+ continue
+ if not (start <= file_date <= end):
+ continue
+ body = extract_body(md_file.read_text(encoding="utf-8"))
+ if body:
+ parts.append(body)
+ return "\n\n---\n\n".join(parts)
+
+
+def pick_ontology_file(arg_path: str | None) -> Path:
+ if arg_path:
+ p = Path(arg_path)
+ if not p.is_absolute():
+ p = ONTOLOGY_DIR / p
+ if not p.exists():
+ raise FileNotFoundError(f"Ontology file not found: {p}")
+ return p
+
+ candidates = sorted(ONTOLOGY_DIR.glob("ontology_*.json"), key=lambda f: f.stat().st_mtime)
+ if not candidates:
+ raise FileNotFoundError(
+ f"No ontology_*.json found in {ONTOLOGY_DIR}. Run gen_ontology.py first."
+ )
+ return candidates[-1] # latest
+
+
+# ── Main ─────────────────────────────────────────────────────────────────────
+def main():
+ parser = argparse.ArgumentParser(description="Build Zep Knowledge Graph from ontology")
+ parser.add_argument("ontology_file", nargs="?", help="Path to ontology JSON (default: latest in output/)")
+ args = parser.parse_args()
+
+ from app.services.graph_builder import GraphBuilderService
+ from app.services.text_processor import TextProcessor
+ from app.services.zep_entity_reader import ZepEntityReader
+
+ # Load ontology
+ ontology_path = pick_ontology_file(args.ontology_file)
+ log(f"Using ontology: {ontology_path.name}")
+ with open(ontology_path, encoding="utf-8") as f:
+ payload = json.load(f)
+ ontology = payload["ontology"]
+
+ # Load news text
+ log(f"Loading news text: {START_DATE} → {END_DATE}")
+ combined_text = load_combined_text(NEWS_DIR, START_DATE, END_DATE)
+ log(f" Text length: {len(combined_text):,} chars")
+
+ svc = GraphBuilderService()
+ t_total = time.time()
+
+ # Create graph
+ log("Creating Zep graph...")
+ graph_id = svc.create_graph(GRAPH_NAME)
+ log(f" graph_id = {graph_id}")
+
+ # Apply ontology
+ log("Applying ontology schema...")
+ svc.set_ontology(graph_id, ontology)
+
+ # Chunk & upload
+ chunks = TextProcessor.split_text(combined_text, CHUNK_SIZE, CHUNK_OVERLAP)
+ log(f"Uploading {len(chunks)} chunks (batch_size={BATCH_SIZE})...")
+
+ episode_uuids = svc.add_text_batches(
+ graph_id, chunks, BATCH_SIZE,
+ progress_callback=lambda msg, _: log(f" {msg}"),
+ )
+ log(f" {len(episode_uuids)} episodes uploaded")
+
+ # Wait for processing
+ log("Waiting for Zep to process episodes...")
+ svc._wait_for_episodes(
+ episode_uuids,
+ progress_callback=lambda msg, _: log(f" {msg}"),
+ timeout=POLL_TIMEOUT,
+ )
+
+ # Fetch result
+ log("Fetching graph stats...")
+ graph_info = svc._get_graph_info(graph_id)
+ elapsed = round(time.time() - t_total, 2)
+
+ log(f"Done in {elapsed}s — nodes={graph_info.node_count}, edges={graph_info.edge_count}")
+ log(f" Entity types: {graph_info.entity_types}")
+
+ out_path = GRAPH_DIR / f"graph_{graph_id}.json"
+ with open(out_path, "w", encoding="utf-8") as f:
+ json.dump({
+ "graph_id": graph_id,
+ "graph_name": GRAPH_NAME,
+ "built_at": datetime.now().isoformat(),
+ "elapsed_seconds": elapsed,
+ "ontology_source": ontology_path.name,
+ "date_range": {"start": str(START_DATE), "end": str(END_DATE)},
+ "chunks_uploaded": len(chunks),
+ "node_count": graph_info.node_count,
+ "edge_count": graph_info.edge_count,
+ "entity_types": graph_info.entity_types,
+ }, f, ensure_ascii=False, indent=2)
+
+ log(f"Saved → {out_path.name}")
+
+ # ── Entity detail ─────────────────────────────────────────────────────────
+ # Raw graph data — lưu nguyên output của get_graph_data()
+ log("Fetching raw graph data...")
+ graph_data = svc.get_graph_data(graph_id)
+ log(f" nodes={graph_data['node_count']}, edges={graph_data['edge_count']}")
+
+ # Filtered entities — lưu nguyên output của filter_defined_entities().to_dict()
+ log("Running entity filter...")
+ defined_types = [e["name"] for e in ontology.get("entity_types", [])]
+ reader = ZepEntityReader()
+ filtered = reader.filter_defined_entities(
+ graph_id=graph_id,
+ defined_entity_types=defined_types,
+ enrich_with_edges=True,
+ )
+ log(f" total={filtered.total_count}, matched={filtered.filtered_count}")
+ log(f" types found: {sorted(filtered.entity_types)}")
+
+ entities_path = GRAPH_DIR / f"entities_{graph_id}.json"
+ with open(entities_path, "w", encoding="utf-8") as f:
+ json.dump({
+ "graph_id": graph_id,
+ "fetched_at": datetime.now().isoformat(),
+ "ontology_entity_types": defined_types,
+ "raw_graph_data": graph_data,
+ "filtered_entities": filtered.to_dict(),
+ }, f, ensure_ascii=False, indent=2)
+
+ log(f"Saved → {entities_path.name}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/test_code_backend/gen_ontogogy_and_graph/gen_ontology.py b/test_code_backend/gen_ontogogy_and_graph/gen_ontology.py
new file mode 100644
index 00000000..16b3e577
--- /dev/null
+++ b/test_code_backend/gen_ontogogy_and_graph/gen_ontology.py
@@ -0,0 +1,109 @@
+"""
+Test: Ontology generation from news articles
+Input : data/news/2026-04/ (date range configured below)
+Output: test_code_backend/output/ontology/ontology__.json
+
+Run:
+ python gen_ontology.py
+"""
+
+import sys
+import json
+import time
+from datetime import datetime, date
+from pathlib import Path
+
+# ── Path setup ───────────────────────────────────────────────────────────────
+SCRIPT_DIR = Path(__file__).parent
+PROJECT_ROOT = SCRIPT_DIR.parent.parent
+BACKEND_DIR = PROJECT_ROOT / "backend"
+ONTOLOGY_DIR = SCRIPT_DIR.parent / "output" / "ontology"
+NEWS_DIR = PROJECT_ROOT / "data" / "news" / "2026-04"
+
+sys.path.insert(0, str(BACKEND_DIR))
+ONTOLOGY_DIR.mkdir(parents=True, exist_ok=True)
+
+# ── Config ───────────────────────────────────────────────────────────────────
+START_DATE = date(2026, 4, 20)
+END_DATE = date(2026, 4, 22)
+
+SIMULATION_REQUIREMENT = (
+ "Simulate social media public opinion dynamics around global oil/energy markets "
+ "and US-Iran geopolitical tensions during April 2026. "
+ "Focus on how governments, corporations, media outlets, financial analysts, and "
+ "ordinary citizens react and interact on platforms like Twitter and Reddit."
+)
+
+
+# ── Helpers ──────────────────────────────────────────────────────────────────
+def log(msg: str):
+ print(f"[{datetime.now().strftime('%H:%M:%S')}] {msg}")
+
+
+def extract_body(md_text: str) -> str:
+ """Strip YAML frontmatter (---...---), return only article body."""
+ s = md_text.strip()
+ if s.startswith("---"):
+ end = s.find("---", 3)
+ if end != -1:
+ return s[end + 3:].strip()
+ return s
+
+
+def load_articles(news_dir: Path, start: date, end: date) -> list[dict]:
+ articles = []
+ for md_file in sorted(news_dir.glob("*.md")):
+ try:
+ file_date = date.fromisoformat(md_file.stem.split("_")[0])
+ except ValueError:
+ continue
+ if not (start <= file_date <= end):
+ continue
+ body = extract_body(md_file.read_text(encoding="utf-8"))
+ if body:
+ articles.append({"filename": md_file.name, "date": str(file_date), "body": body})
+ return articles
+
+
+# ── Main ─────────────────────────────────────────────────────────────────────
+def main():
+ from app.services.ontology_generator import OntologyGenerator
+
+ log(f"Loading news: {START_DATE} → {END_DATE}")
+ articles = load_articles(NEWS_DIR, START_DATE, END_DATE)
+ log(f" {len(articles)} articles loaded")
+
+ log("Calling LLM to generate ontology...")
+ t0 = time.time()
+ ontology = OntologyGenerator().generate(
+ document_texts=[a["body"] for a in articles],
+ simulation_requirement=SIMULATION_REQUIREMENT,
+ )
+ elapsed = round(time.time() - t0, 2)
+
+ entity_names = [e["name"] for e in ontology.get("entity_types", [])]
+ edge_names = [e["name"] for e in ontology.get("edge_types", [])]
+ log(f"Done in {elapsed}s")
+ log(f" Entities ({len(entity_names)}): {entity_names}")
+ log(f" Edges ({len(edge_names)}): {edge_names}")
+
+ date_range = f"{START_DATE.strftime('%Y%m%d')}-{END_DATE.strftime('%Y%m%d')}"
+ ts = datetime.now().strftime("%H%M%S")
+ out_path = ONTOLOGY_DIR / f"ontology_{date_range}_{ts}.json"
+
+ with open(out_path, "w", encoding="utf-8") as f:
+ json.dump({
+ "meta": {
+ "generated_at": datetime.now().isoformat(),
+ "elapsed_seconds": elapsed,
+ "date_range": {"start": str(START_DATE), "end": str(END_DATE)},
+ "article_count": len(articles),
+ },
+ "ontology": ontology,
+ }, f, ensure_ascii=False, indent=2)
+
+ log(f"Saved → {out_path.name}")
+
+
+if __name__ == "__main__":
+ main()