[feat]: Replace Zep Cloud to Graphiti

This commit is contained in:
KieuVui 2026-06-13 08:20:44 +00:00
parent a93873ac34
commit 6bafb8b270
16 changed files with 631 additions and 391 deletions

140
PERFORMANCE_TUNING.md Normal file
View File

@ -0,0 +1,140 @@
# Performance Tuning — Tăng tốc pipeline khi có resource lớn
Tài liệu này liệt kê các **param có thể nâng lên để chạy nhanh hơn** khi bạn có máy/GPU/LLM-server mạnh hơn.
Mỗi mục ghi rõ: **file + dòng**, giá trị hiện tại, giá trị đề xuất, và **giúp nhanh ở bước nào**.
> ⚠️ **Nút thắt thật không nằm ở code mà ở 2 server LLM/embedding.**
> Toàn bộ semaphore/concurrency bên dưới bị giới hạn bởi số request đồng thời mà
> - LLM server (`LLM_BASE_URL`, vd `localhost:8027`) và
> - embedding server (`EMBEDDING_BASE_URL`, vd `localhost:8026`)
>
> chịu được. Tăng các con số vượt quá khả năng 2 server này sẽ gây **timeout / 429 / 400**, KHÔNG nhanh hơn.
> Quy tắc: nâng dần, theo dõi log, dừng khi bắt đầu thấy lỗi.
---
## ✅ ĐÃ ÁP DỤNG — cấu hình cho 2 model hiện tại (cập nhật mới nhất)
Server hiện tại:
- **LLM:** `Qwen/Qwen3.6-27B` @ `localhost:8027``max_model_len = 262144` (256K)
- **Embedding:** `Qwen/Qwen3-Embedding-4B` @ `localhost:8026``max_model_len = 40960` (40K)
> ⚠️ **Đã fix tên model:** `.env` trước ghi `Qwen/Qwen3.6-27B-FP8` (sai → gây 404). Server chỉ phục vụ `Qwen/Qwen3.6-27B`.
Các giá trị đã set (tận dụng cửa sổ context lớn 256K):
| Param | File:dòng | Cũ → **Mới** | Lý do |
|---|---|---|---|
| `LLM_MODEL_NAME` | [.env:6](.env#L6) | `…-27B-FP8`**`…-27B`** | Fix 404 (bắt buộc) |
| `SEMAPHORE_LIMIT` | [.env:26](.env#L26) | `10`**`30`** | Build graph nhanh hơn |
| `CHUNK_TOKEN_SIZE` | [.env:27](.env#L27) | `3000`**`8000`** | Ít chunk hơn (< embed 40960) |
| `max_tokens` ×2 | [backend/app/utils/llm_client.py:44](backend/app/utils/llm_client.py#L44), [:91](backend/app/utils/llm_client.py#L91) | `16000`**`32000`** | Output dài hơn |
| `MAX_TEXT_LENGTH_FOR_LLM` | [backend/app/services/ontology_generator.py:291](backend/app/services/ontology_generator.py#L291) | `40000`**`400000`** | Ontology đọc nhiều text hơn (~100K token) |
| `token_limit` / `message_window_size` | [backend/scripts/run_parallel_simulation.py:1128](backend/scripts/run_parallel_simulation.py#L1128) | `24000`/`25` → **`150000`/`50`** | Agent nhớ nhiều hơn, ít bị cắt |
| `parallel_profile_count` | [backend/app/services/simulation_manager.py:352](backend/app/services/simulation_manager.py#L352) | `3`**`15`** | Sinh profile song song |
| `parallel_count` (default) | [backend/app/services/oasis_profile_generator.py:1198](backend/app/services/oasis_profile_generator.py#L1198) | `5`**`15`** | (đồng bộ với trên) |
| `semaphore` ×2 (oasis.make) | [backend/scripts/run_parallel_simulation.py:1340](backend/scripts/run_parallel_simulation.py#L1340), [:1535](backend/scripts/run_parallel_simulation.py#L1535) | `8`**`30`** | Nhiều agent gọi LLM/round |
| `batch_size` (build) | [backend/app/api/graph.py:459](backend/app/api/graph.py#L459) | `3`**`8`** | Ít round-trip khi nạp chunk |
**Cần làm sau khi sửa:** restart backend để `.env` có hiệu lực (`cd backend && FLASK_PORT=5002 uv run python run.py`).
---
## 🎯 TL;DR — Sửa 3 thứ này trước (ăn nhất)
| Ưu tiên | Param | File:dòng | Hiện tại | Đề xuất (máy mạnh) | Tăng tốc bước |
|---|---|---|---|---|---|
| 1 | `SEMAPHORE_LIMIT` | [.env:26](.env#L26) | `10` | `30` | **Build graph** (chậm nhất) |
| 2 | `parallel_profile_count` | [backend/app/services/simulation_manager.py:352](backend/app/services/simulation_manager.py#L352) | `3` | `15` | **Sinh agent profile** |
| 3 | `semaphore` (oasis.make) | [backend/scripts/run_parallel_simulation.py:1340](backend/scripts/run_parallel_simulation.py#L1340), [:1535](backend/scripts/run_parallel_simulation.py#L1535) | `8` | `30` | **Simulation** (nhiều agent gọi LLM) |
---
## 🟢 Nhóm 1 — Build knowledge graph (bước chậm nhất)
### 1.1 `SEMAPHORE_LIMIT` — ⭐ quan trọng nhất
- **File:** [.env:26](.env#L26) → `SEMAPHORE_LIMIT=10`
- **Là gì:** số thao tác LLM/embedding **đồng thời**`graphiti_core` chạy khi build graph.
(Đọc bởi thư viện tại `graphiti_core/helpers.py`, default lib = 20.)
- **Đề xuất:** `20``50` (giới hạn bởi sức chịu của LLM + embedding server).
- **Giúp gì:** Build graph là bước tốn thời gian nhất (extract entity + embedding cho từng chunk). Đây là đòn bẩy lớn nhất.
### 1.2 `USE_PARALLEL_RUNTIME`
- **File:** [.env:28](.env#L28) → `USE_PARALLEL_RUNTIME=false`
- **Là gì:** bật Neo4j parallel runtime cho các Cypher query.
- **Đề xuất:** `true`**CHỈ khi dùng Neo4j Enterprise** (Community Edition không hỗ trợ, bật vô tác dụng).
- **Giúp gì:** truy vấn graph nhanh hơn khi Neo4j có nhiều core.
### 1.3 `CHUNK_TOKEN_SIZE`
- **File:** [.env:27](.env#L27) → `CHUNK_TOKEN_SIZE=3000`
- **Là gì:** kích thước mỗi chunk (token) khi graphiti tự chia text.
- **Đề xuất:** `5000``8000` — **CHỈ khi LLM/embedding có cửa sổ context lớn** (model hiện tại 32K nên cẩn thận).
- **Giúp gì:** chunk to hơn → ít chunk hơn → ít vòng LLM call hơn khi build. (Đánh đổi: mỗi call nặng hơn.)
### 1.4 `batch_size` khi build (hardcode — phải sửa code)
- **File:** [backend/app/api/graph.py:459](backend/app/api/graph.py#L459) → `batch_size=3`
(default cũng ở [backend/app/services/graph_builder.py:67](backend/app/services/graph_builder.py#L67) và [:294](backend/app/services/graph_builder.py#L294))
- **Là gì:** số chunk gửi đi mỗi đợt. Endpoint `build` KHÔNG nhận param này từ request → phải sửa trực tiếp.
- **Đề xuất:** `5``10`.
- **Giúp gì:** giảm số lần round-trip khi nạp chunk vào graph.
---
## 🟡 Nhóm 2 — Simulation (bước chậm thứ 2)
### 2.1 `parallel_profile_count` — sinh agent profile song song
- **File:** [backend/app/services/simulation_manager.py:352](backend/app/services/simulation_manager.py#L352) → `parallel_profile_count: int = 3`
(worker thực thi ở [backend/app/services/oasis_profile_generator.py:1198](backend/app/services/oasis_profile_generator.py#L1198) → `parallel_count: int = 5`)
- **Là gì:** số thread sinh profile đồng thời, mỗi profile = 1 LLM call.
- **Đề xuất:** `10``20`.
- **Giúp gì:** với 50 profile: 3 thread ≈ 100250s; 15 thread nhanh ~5×.
### 2.2 `semaphore` trong `oasis.make()` — LLM call đồng thời mỗi round
- **File:**
- [backend/scripts/run_parallel_simulation.py:1340](backend/scripts/run_parallel_simulation.py#L1340) (Twitter) → `semaphore=8`
- [backend/scripts/run_parallel_simulation.py:1535](backend/scripts/run_parallel_simulation.py#L1535) (Reddit) → `semaphore=8`
- [backend/scripts/run_twitter_simulation.py:597](backend/scripts/run_twitter_simulation.py#L597) → `semaphore=30`
- [backend/scripts/run_reddit_simulation.py:582](backend/scripts/run_reddit_simulation.py#L582) → `semaphore=30`
- **Là gì:** giới hạn số agent gọi LLM cùng lúc trong 1 round.
- **Đề xuất:** `20``40` (đã hạ xuống 8 cho server nhỏ; server mạnh thì nới ra).
- **Giúp gì:** mỗi round simulation chạy nhanh hơn khi nhiều agent xử lý song song.
---
## 🔵 Nhóm 3 — Token / context (chỉ nâng SAU KHI đổi sang model cửa sổ lớn)
> ⚠️ Mấy param này đã được **hạ xuống** để vừa model 32K hiện tại (`max_total_tokens=32768`).
> Chúng **không tăng tốc trực tiếp**, mà tăng *throughput mỗi call* → ít vòng lặp/ít bị cắt context.
> **Nâng lên sẽ gây lỗi HTTP 400 nếu model vẫn là 32K.** Chỉ nâng khi đã trỏ sang model/endpoint cửa sổ lớn (vd 128K).
| Param | File:dòng | Hiện tại | Khi model lớn | Vai trò |
|---|---|---|---|---|
| `max_tokens` (chat) | [backend/app/utils/llm_client.py:44](backend/app/utils/llm_client.py#L44) | `16000` | `32000``50000` | output tối đa mỗi call |
| `max_tokens` (chat_json) | [backend/app/utils/llm_client.py:91](backend/app/utils/llm_client.py#L91) | `16000` | `32000``50000` | output JSON (ontology) |
| `MAX_TEXT_LENGTH_FOR_LLM` | [backend/app/services/ontology_generator.py:291](backend/app/services/ontology_generator.py#L291) | `40000` | `100000`+ | số ký tự input cho ontology (hiện cắt bớt) |
| `token_limit` (agent memory) | [backend/scripts/run_parallel_simulation.py:1128](backend/scripts/run_parallel_simulation.py#L1128) | `24000` | `100000`+ | context history mỗi agent giữ lại |
| `message_window_size` | [backend/scripts/run_parallel_simulation.py:1128](backend/scripts/run_parallel_simulation.py#L1128) | `25` | `50`+ | số message agent nhớ |
---
## ⚙️ Nhóm 4 — Throughput-vs-độ-sâu (đánh đổi chất lượng/tốc độ, không phải resource)
Mấy cái này không cần máy mạnh — chúng đổi **độ sâu xử lý** lấy **tốc độ**. Giảm xuống = nhanh hơn nhưng kết quả nông hơn.
| Param | File:dòng | Hiện tại | Để chạy nhanh |
|---|---|---|---|
| `OASIS_DEFAULT_MAX_ROUNDS` | [backend/app/config.py:55](backend/app/config.py#L55) (env `OASIS_DEFAULT_MAX_ROUNDS`) | `10` | giảm còn `3``5` khi test nhanh |
| `REPORT_AGENT_MAX_TOOL_CALLS` | [backend/app/config.py:69](backend/app/config.py#L69) | `5` | giảm → report nhanh hơn, ít truy vấn graph hơn |
| `REPORT_AGENT_MAX_REFLECTION_ROUNDS` | [backend/app/config.py:70](backend/app/config.py#L70) | `2` | giảm còn `1` → report nhanh hơn |
---
## 📌 Lưu ý vận hành
1. **Sửa `.env` → phải restart backend** (biến môi trường chỉ đọc lúc khởi động; Flask reloader KHÔNG reload `.env`).
```bash
cd backend && FLASK_PORT=5002 uv run python run.py
```
2. **Sửa code `.py` → Flask reloader tự nạp lại** (nếu debug mode bật), không cần restart.
3. **Nâng dần, đừng nhảy vọt.** Tăng semaphore → xem log có `429`/`timeout`/`400` không → nếu có thì hạ lại.
4. **Trần trên = sức chịu của LLM + embedding server**, không phải code. Đó mới là nút thắt thật.

View File

@ -298,8 +298,8 @@ def build_graph():
# Kiểm tra cấu hình
errors = []
if not Config.ZEP_API_KEY:
errors.append("ZEP_API_KEY not configured")
if not Config.NEO4J_URI or not Config.NEO4J_PASSWORD:
errors.append("NEO4J_URI / NEO4J_PASSWORD not configured")
if errors:
logger.error(f"Configuration error: {errors}")
return jsonify({
@ -396,9 +396,9 @@ def build_graph():
message="Initializing graph builder service..."
)
# INITIALIZE GRAPH BUILDER
# Creates service instance with Zep Cloud API client
builder = GraphBuilderService(api_key=Config.ZEP_API_KEY)
# INITIALIZE GRAPH BUILDER
# Graphiti (Neo4j) backend — cấu hình lấy lazy qua get_graphiti()
builder = GraphBuilderService()
# Chunk text
task_manager.update_task(
@ -454,9 +454,9 @@ def build_graph():
# [4] - Sends text chunks as episodes to Zep for entity extraction
episode_uuids = builder.add_text_batches(
graph_id,
graph_id,
chunks,
batch_size=3,
batch_size=8,
progress_callback=add_progress_callback
)
@ -591,13 +591,13 @@ def get_graph_data(graph_id: str):
Lấy dữ liệu graph (nodes edges)
"""
try:
if not Config.ZEP_API_KEY:
if not Config.NEO4J_URI or not Config.NEO4J_PASSWORD:
return jsonify({
"success": False,
"error": "ZEP_API_KEY not configured"
"error": "NEO4J_URI / NEO4J_PASSWORD not configured"
}), 500
builder = GraphBuilderService(api_key=Config.ZEP_API_KEY)
builder = GraphBuilderService()
graph_data = builder.get_graph_data(graph_id)
return jsonify({
@ -616,16 +616,16 @@ def get_graph_data(graph_id: str):
@graph_bp.route('/delete/<graph_id>', methods=['DELETE'])
def delete_graph(graph_id: str):
"""
Xóa Zep graph
Xóa graph (Graphiti + Neo4j)
"""
try:
if not Config.ZEP_API_KEY:
if not Config.NEO4J_URI or not Config.NEO4J_PASSWORD:
return jsonify({
"success": False,
"error": "ZEP_API_KEY not configured"
"error": "NEO4J_URI / NEO4J_PASSWORD not configured"
}), 500
builder = GraphBuilderService(api_key=Config.ZEP_API_KEY)
builder = GraphBuilderService()
builder.delete_graph(graph_id)
return jsonify({

View File

@ -58,10 +58,10 @@ def get_graph_entities(graph_id: str):
enrich: lấy thông tin cạnh liên quan hay không (mặc định true)
"""
try:
if not Config.ZEP_API_KEY:
if not Config.NEO4J_URI or not Config.NEO4J_PASSWORD:
return jsonify({
"success": False,
"error": "ZEP_API_KEY is not configured"
"error": "NEO4J_URI / NEO4J_PASSWORD is not configured"
}), 500
entity_types_str = request.args.get('entity_types', '')
@ -95,10 +95,10 @@ def get_graph_entities(graph_id: str):
def get_entity_detail(graph_id: str, entity_uuid: str):
"""Lấy thông tin chi tiết của một thực thể."""
try:
if not Config.ZEP_API_KEY:
if not Config.NEO4J_URI or not Config.NEO4J_PASSWORD:
return jsonify({
"success": False,
"error": "ZEP_API_KEY is not configured"
"error": "NEO4J_URI / NEO4J_PASSWORD is not configured"
}), 500
reader = ZepEntityReader()
@ -128,10 +128,10 @@ def get_entity_detail(graph_id: str, entity_uuid: str):
def get_entities_by_type(graph_id: str, entity_type: str):
"""Lấy toàn bộ thực thể theo loại chỉ định."""
try:
if not Config.ZEP_API_KEY:
if not Config.NEO4J_URI or not Config.NEO4J_PASSWORD:
return jsonify({
"success": False,
"error": "ZEP_API_KEY is not configured"
"error": "NEO4J_URI / NEO4J_PASSWORD is not configured"
}), 500
enrich = request.args.get('enrich', 'true').lower() == 'true'
@ -309,7 +309,7 @@ def _check_simulation_prepared(simulation_id: str) -> tuple:
# - completed: đã chạy xong, nghĩa là đã chuẩn bị xong từ trước
# - stopped: đã dừng, nghĩa là đã chuẩn bị xong từ trước
# - failed: chạy thất bại (nhưng phần chuẩn bị đã hoàn tất)
prepared_statuses = ["ready", "preparing", "running", "completed", "stopped", "failed", "paused"]
prepared_statuses = ["ready", "preparing", "running", "completed", "stopped", "failed"]
if status in prepared_statuses and config_generated:
# Lấy thông tin thống kê file
@ -383,7 +383,7 @@ def prepare_simulation():
"simulation_id": "sim_xxxx", // bắt buộc, simulation ID
"entity_types": ["Student", "PublicFigure"], // tùy chọn, chỉ định loại thực thể
"use_llm_for_profiles": true, // tùy chọn, dùng LLM để sinh persona hay không
"parallel_profile_count": 5, // tùy chọn, số lượng sinh persona song song, mặc định 5
"parallel_profile_count": 15, // tùy chọn, số lượng sinh persona song song, mặc định 15
"force_regenerate": false // tùy chọn, buộc sinh lại, mặc định false
}
@ -468,7 +468,7 @@ def prepare_simulation():
entity_types_list = data.get('entity_types')
use_llm_for_profiles = data.get('use_llm_for_profiles', True)
parallel_profile_count = data.get('parallel_profile_count', 20)
parallel_profile_count = data.get('parallel_profile_count', 15)
# ========== Đồng bộ lấy số lượng thực thể (trước khi chạy tác vụ nền) ==========
# Nhờ đó frontend có thể lấy ngay tổng số Agent dự kiến sau khi gọi prepare

View File

@ -49,7 +49,6 @@ class Project:
simulation_requirement: Optional[str] = None
chunk_size: int = 500
chunk_overlap: int = 50
llm_model_name: Optional[str] = None
# Thông tin lỗi
error: Optional[str] = None
@ -64,7 +63,6 @@ class Project:
"updated_at": self.updated_at,
"files": self.files,
"total_text_length": self.total_text_length,
"llm_model_name": self.llm_model_name,
"ontology": self.ontology,
"analysis_summary": self.analysis_summary,
"graph_id": self.graph_id,
@ -90,7 +88,6 @@ class Project:
updated_at=data.get('updated_at', ''),
files=data.get('files', []),
total_text_length=data.get('total_text_length', 0),
llm_model_name=data.get('llm_model_name'),
ontology=data.get('ontology'),
analysis_summary=data.get('analysis_summary'),
graph_id=data.get('graph_id'),
@ -154,8 +151,7 @@ class ProjectManager:
name=name,
status=ProjectStatus.CREATED,
created_at=now,
updated_at=now,
llm_model_name=Config.LLM_MODEL_NAME
updated_at=now
)
# Thiết lập các thư mục con trong không gian thư mục của project

View File

@ -1,22 +1,24 @@
"""
Dịch vụ xây dựng Đồ thị Tri thức (Knowledge Graph)
API 2: Sử dụng Zep API để xây dựng một Standalone Graph (Đồ thị độc lập)
API 2: Dùng graphiti_core (Neo4j backend) để xây dựng một Standalone Graph (Đồ thị độc lập)
"""
import os
import re # changed
import uuid
import time
import uuid
import threading
from typing import Dict, Any, List, Optional, Callable
from datetime import datetime, timezone
from typing import Dict, Any, List, Optional, Callable, Type
from dataclasses import dataclass
from zep_cloud.client import Zep
from zep_cloud import EpisodeData, EntityEdgeSourceTarget
from pydantic import BaseModel, Field
from graphiti_core.nodes import EntityNode, EpisodeType
from ..config import Config
from ..models.task import TaskManager, TaskStatus
from ..utils.zep_paging import fetch_all_nodes, fetch_all_edges
from ..utils.graphiti_client import get_graphiti, run_async
from .text_processor import TextProcessor
@ -40,16 +42,20 @@ class GraphInfo:
class GraphBuilderService:
"""
Dịch vụ tạo lập Graph
Đảm nhiệm logic gọi request lên Zep API để thiết lập Graph
Đảm nhiệm logic dùng graphiti_core (Neo4j) để thiết lập Graph
"""
def __init__(self, api_key: Optional[str] = None):
self.api_key = api_key or Config.ZEP_API_KEY
if not self.api_key:
raise ValueError("ZEP_API_KEY has not been configured.")
self.client = Zep(api_key=self.api_key)
# api_key giữ lại trong chữ ký để tương thích caller cũ, nhưng không còn dùng
# (Graphiti dùng Neo4j + LLM config, lấy lazy qua get_graphiti()).
self.api_key = api_key
self.task_manager = TaskManager()
# Ontology được build sẵn (Pydantic thuần) và truyền vào mỗi add_episode,
# vì Graphiti không có set_ontology server-side như Zep.
self._entity_types: Dict[str, Type[BaseModel]] = {}
self._edge_types: Dict[str, Type[BaseModel]] = {}
self._edge_type_map: Dict[tuple, List[str]] = {}
def build_graph_async(
self,
@ -191,115 +197,95 @@ class GraphBuilderService:
self.task_manager.fail_task(task_id, error_msg)
def create_graph(self, name: str) -> str:
"""Graph Creation: Initializes a new graph in Zep Cloud with a unique ID"""
"""Sinh graph_id mới (dùng làm group_id của Graphiti).
# Generate unique graph_id
Graphiti không cần đăng partition trước group_id string tự do,
sẽ được tạo ngầm khi add_episode đầu tiên ghi vào. vậy method này
chỉ sinh & trả về id, không gọi API nào.
"""
graph_id = f"mirofish_{uuid.uuid4().hex[:16]}"
# Call Zep API create()
self.client.graph.create(
graph_id=graph_id,
name=name,
description="MiroFish Social Simulation Graph"
)
return graph_id
def set_ontology(self, graph_id: str, ontology: Dict[str, Any]):
"""
Cấu hình dữ liệu Ontology (Bản thể học) cho Graph trên server Zep (Public access)
Ontology Setup: Defines the schema for entities and relationships using dynamic class creation.
Build schema Ontology (entity/edge types) dưới dạng Pydantic model thuần.
Graphiti không API set_ontology server-side như Zep. Thay vào đó,
entity_types/edge_types/edge_type_map được truyền vào MỖI lần add_episode.
Method này build sẵn lưu trên self để add_text_batches dùng lại.
"""
import warnings
from typing import Optional
from pydantic import Field
from zep_cloud.external_clients.ontology import EntityModel, EntityText, EdgeModel
# Ẩn bỏ đi các Warning (Cảnh báo) của thư viện Pydantic v2 liên quan đến Field(default=None)
# Vì đây là format bắt buộc phải có từ Zep SDK, các cảnh báo này phát sinh do tự động khởi tạo lớp ảo, hoàn toàn có thể bỏ qua được.
warnings.filterwarnings('ignore', category=UserWarning, module='pydantic')
# Danh sách các tên định danh (variable/name) trùng với từ khoá bảo lưu của Zep, không được dùng làm tên thuộc tính
RESERVED_NAMES = {'uuid', 'name', 'group_id', 'name_embedding', 'summary', 'created_at'}
# Các tên field trùng với EntityNode.model_fields của graphiti — không được dùng
# làm attribute (graphiti raise EntityTypeValidationError nếu trùng).
RESERVED_NAMES = {
'uuid', 'name', 'group_id', 'labels',
'name_embedding', 'summary', 'attributes', 'created_at',
}
def safe_attr_name(attr_name: str) -> str:
"""Hàm thay đổi các tên thuộc tính bị trùng với keyword của hệ thống để an toàn hơn"""
"""Đổi tên attribute nếu trùng field bảo lưu của graphiti."""
if attr_name.lower() in RESERVED_NAMES:
return f"entity_{attr_name}"
return attr_name
# Khởi tạo động (Dynamic Class Creation) các Model Loại Thực thể từ JSON đầu vào
entity_types = {}
# Processes each entity type from ontology definition
# --- Entity types (Pydantic thuần, key = tên PascalCase từ ontology) ---
entity_types: Dict[str, Type[BaseModel]] = {}
for entity_def in ontology.get("entity_types", []):
name = entity_def["name"]
description = entity_def.get("description", f"A {name} entity.")
# Chuẩn bị file từ điển cho Attribute và kiểu chú thích (Theo chuẩn Pydantic v2)
attrs = {"__doc__": description}
annotations = {}
attrs: Dict[str, Any] = {"__doc__": description}
annotations: Dict[str, Any] = {}
for attr_def in entity_def.get("attributes", []):
attr_name = safe_attr_name(attr_def["name"]) # Áp dụng hàm chống bị trùng từ khoá
attr_name = safe_attr_name(attr_def["name"])
attr_desc = attr_def.get("description", attr_name)
# Zep API bắt buộc phải nhận vào field description
attrs[attr_name] = Field(description=attr_desc, default=None)
annotations[attr_name] = Optional[EntityText] # Chú thích kiểu dữ liệu
attrs[attr_name] = Field(default=None, description=attr_desc)
annotations[attr_name] = Optional[str]
attrs["__annotations__"] = annotations
# Dynamic class generation - Create Pydnatic model class for entity type at runtime
entity_class = type(name, (EntityModel,), attrs)
entity_class = type(name, (BaseModel,), attrs)
entity_class.__doc__ = description
entity_types[name] = entity_class # Store in entity_types dict
# Tương tự, dựa vào JSON để khởi tạo động khai báo các Model Loại Quan Hệ
edge_definitions = {}
entity_types[name] = entity_class
# --- Edge types (key = tên UPPER_SNAKE_CASE) + edge_type_map ---
edge_types: Dict[str, Type[BaseModel]] = {}
edge_type_map: Dict[tuple, List[str]] = {}
for edge_def in ontology.get("edge_types", []):
name = edge_def["name"]
description = edge_def.get("description", f"A {name} relationship.")
# Dọn các attribute dictionary và typing tương tự
attrs = {"__doc__": description}
annotations = {}
for attr_def in edge_def.get("attributes", []):
attr_name = safe_attr_name(attr_def["name"]) # Filter an toàn
attr_name = safe_attr_name(attr_def["name"])
attr_desc = attr_def.get("description", attr_name)
# Đảm bảo giữ format Zep API
attrs[attr_name] = Field(description=attr_desc, default=None)
annotations[attr_name] = Optional[str] # Định dạng Data cho thuộc tình của loại Quan Hệ là chuỗi String
attrs[attr_name] = Field(default=None, description=attr_desc)
annotations[attr_name] = Optional[str]
attrs["__annotations__"] = annotations
# Khởi tạo Class động với Tên chuẩn format (PascalCase)
# Class name PascalCase chỉ để debug; KEY của dict mới là cái graphiti dùng.
class_name = ''.join(word.capitalize() for word in name.split('_'))
# Creates Pydantic model class for relationship type
edge_class = type(class_name, (EdgeModel,), attrs)
edge_class = type(class_name, (BaseModel,), attrs)
edge_class.__doc__ = description
# Mapping thông số luồng thực thể gắn kết với Quan Hệ (Source/Targets config)
source_targets = []
for st in edge_def.get("source_targets", []):
source_targets.append(
EntityEdgeSourceTarget(
source=st.get("source", "Entity"),
target=st.get("target", "Entity")
)
)
edge_types[name] = edge_class
# Build edge_type_map: (source_label, target_label) -> [edge_name, ...]
source_targets = edge_def.get("source_targets", [])
if source_targets:
edge_definitions[name] = (edge_class, source_targets) # Store in edge_definitions dict
# Action Gọi lệnh thay đổi Ontology cho môi trường GraphID của Zep
if entity_types or edge_definitions:
# Call Zep set_ontology()
self.client.graph.set_ontology(
graph_ids=[graph_id],
entities=entity_types if entity_types else None,
edges=edge_definitions if edge_definitions else None,
)
for st in source_targets:
key = (st.get("source", "Entity"), st.get("target", "Entity"))
edge_type_map.setdefault(key, []).append(name)
else:
# Không khai báo source/target cụ thể → catch-all (Entity, Entity)
edge_type_map.setdefault(("Entity", "Entity"), []).append(name)
# Lưu lại để truyền vào add_episode (không gọi API nào ở đây).
self._entity_types = entity_types
self._edge_types = edge_types
self._edge_type_map = edge_type_map
def add_text_batches(
self,
@ -308,71 +294,70 @@ class GraphBuilderService:
batch_size: int = 3,
progress_callback: Optional[Callable] = None
) -> List[str]:
"""Tải các đoạn văn bản (text chunks) lên Graph theo từng gói nhỏ (batch) và trả về id (Episode UUID) của mọi phân đoạn dữ liệu gửi đi."""
episode_uuids = []
"""Nạp từng chunk văn bản vào graph qua graphiti.add_episode (tuần tự).
Graphiti add_episode xử đồng bộ (await xong = đã extract & ghi Neo4j)
khuyến nghị chạy tuần tự để giữ ngữ cảnh temporal giữa các episode liên tiếp.
Trả về list episode uuid.
`batch_size` không còn nghĩa "nhiều episode/1 request" (mỗi chunk = 1 episode);
giữ param cho tương thích caller, chỉ dùng để định nhịp progress.
"""
graphiti = get_graphiti()
episode_uuids: List[str] = []
total_chunks = len(chunks)
for i in range(0, total_chunks, batch_size):
batch_chunks = chunks[i:i + batch_size]
batch_num = i // batch_size + 1
total_batches = (total_chunks + batch_size - 1) // batch_size
if total_chunks == 0:
return episode_uuids
# entity/edge types: truyền None nếu rỗng để graphiti dùng default.
entity_types = self._entity_types or None
edge_types = self._edge_types or None
edge_type_map = self._edge_type_map or None
for i, chunk in enumerate(chunks):
if progress_callback:
progress = (i + len(batch_chunks)) / total_chunks
progress_callback(
f"Sending data batch {batch_num}/{total_batches} ({len(batch_chunks)} chunks)...",
progress
f"Adding chunk {i + 1}/{total_chunks} to graph...",
(i + 1) / total_chunks,
)
# Create EpisodeData objects
episodes = [
EpisodeData(data=chunk, type="text")
for chunk in batch_chunks
]
# Khởi chạy gửi cho Zep Server
max_retries = 10 # changed
for attempt in range(max_retries): # changed
# Lớp retry mỏng phòng LLM backend rate-limit / lỗi tạm thời.
max_retries = 3
delay = 2.0
for attempt in range(max_retries):
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
result = run_async(graphiti.add_episode(
name=f"chunk_{i}",
episode_body=chunk,
source_description="MiroFish text chunk",
reference_time=datetime.now(timezone.utc),
source=EpisodeType.text,
group_id=graph_id,
entity_types=entity_types,
edge_types=edge_types,
edge_type_map=edge_type_map,
))
ep_uuid = getattr(result.episode, "uuid", None)
if ep_uuid:
episode_uuids.append(ep_uuid)
break
except Exception as e:
if attempt < max_retries - 1:
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
progress_callback(
f"Chunk {i + 1} failed ({str(e)[:80]}), retry in {delay:.0f}s...",
(i + 1) / total_chunks,
)
time.sleep(delay)
delay *= 2
else:
if progress_callback:
progress_callback(
f"Chunk {i + 1} failed after {max_retries} attempts: {str(e)[:120]}",
(i + 1) / total_chunks,
)
raise
return episode_uuids
def _wait_for_episodes(
@ -381,69 +366,24 @@ class GraphBuilderService:
progress_callback: Optional[Callable] = None,
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:
if progress_callback:
progress_callback("No episodes to scan (Progress 100%)", 1.0)
return
start_time = time.time()
pending_episodes = set(episode_uuids)
completed_count = 0
total_episodes = len(episode_uuids)
"""No-op với Graphiti: add_episode đã xử lý đồng bộ (extract + ghi Neo4j
xong trước khi return), nên không cần poll trạng thái processing.
Giữ method để tương thích caller (_build_graph_worker).
"""
if progress_callback:
progress_callback(f"Waiting for analysis of {total_episodes} text chunks to begin...", 0)
while pending_episodes:
# Ngắt thoát và trả về lỗi nếu bị Timeout (Chạy quá thời gian cho phép)
if time.time() - start_time > timeout:
if progress_callback:
progress_callback(
f"Some text segments have timed out, but {completed_count}/{total_episodes} have completed successfully",
completed_count / total_episodes
)
break
# Duyệt vòng lặp mỗi episode uuid để lấy cập nhật tiến trình check của từng episode một
for ep_uuid in list(pending_episodes):
try:
# CHECK EPISODE STATUS - Queries individual episode processing status
episode = self.client.graph.episode.get(uuid_=ep_uuid)
# CHECK PROCESSED FLAG - Determines if Zep
is_processed = getattr(episode, 'processed', False)
if is_processed:
# if processed: remove from set
pending_episodes.remove(ep_uuid)
completed_count += 1
except Exception as e:
# Tạm thời bỏ qua nếu request lỗi, vòng lặp kế theo sẽ tự động call tiếp để get status
pass
elapsed = int(time.time() - start_time)
# update progress callback
if progress_callback:
progress_callback(
f"Zep is processing in the background... {completed_count}/{total_episodes} done, {len(pending_episodes)} tasks remaining ({elapsed}s elapsed)",
completed_count / total_episodes if total_episodes > 0 else 0
)
if pending_episodes:
time.sleep(3) # Lặp chu kỳ check mỗi 3 giây
if progress_callback:
# Final completion message
progress_callback(f"Data upload process completed: {completed_count}/{total_episodes}", 1.0)
progress_callback("Processing complete (synchronous)", 1.0)
return
def _get_graph_info(self, graph_id: str) -> GraphInfo:
"""Lấy/Get dữ liệu Graph Info hiện tại"""
# Load các điểm nút/Entity đang có (Qua trình duyệt web/paging)
nodes = fetch_all_nodes(self.client, graph_id)
driver = get_graphiti().driver
# Load các điểm nút/Entity đang có (phân trang)
nodes = fetch_all_nodes(driver, graph_id)
# Lấy theo mảng phân trang thông tin các Edges/Mối Liên Kết
edges = fetch_all_edges(self.client, graph_id)
edges = fetch_all_edges(driver, graph_id)
# Nối lại và thống kê những Entity Types
entity_types = set()
@ -471,13 +411,14 @@ class GraphBuilderService:
Một object Dictionary bao hàm thông tin dữ liệu về Mạng lưới Cụm (nodes) Cạnh (edges),
toàn bộ chi tiết đi kèm khác (Time khởi tạo, Property).
"""
nodes = fetch_all_nodes(self.client, graph_id)
edges = fetch_all_edges(self.client, graph_id)
driver = get_graphiti().driver
nodes = fetch_all_nodes(driver, graph_id)
edges = fetch_all_edges(driver, graph_id)
# Giữ một map tra cứu để phục vụ lấy 'Tên' nhanh theo ID UUID
node_map = {}
for node in nodes:
node_map[node.uuid_] = node.name or ""
node_map[node.uuid] = node.name or ""
nodes_data = []
for node in nodes:
@ -487,7 +428,7 @@ class GraphBuilderService:
created_at = str(created_at)
nodes_data.append({
"uuid": node.uuid_,
"uuid": node.uuid,
"name": node.name,
"labels": node.labels or [],
"summary": node.summary or "",
@ -514,7 +455,7 @@ class GraphBuilderService:
fact_type = getattr(edge, 'fact_type', None) or edge.name or ""
edges_data.append({
"uuid": edge.uuid_,
"uuid": edge.uuid,
"name": edge.name or "",
"fact": edge.fact or "",
"fact_type": fact_type,
@ -539,6 +480,7 @@ class GraphBuilderService:
}
def delete_graph(self, graph_id: str):
"""删除图谱"""
self.client.graph.delete(graph_id=graph_id)
"""Xóa toàn bộ node/edge/episode thuộc group_id (DETACH DELETE cascade)."""
driver = get_graphiti().driver
run_async(EntityNode.delete_by_group_id(driver, graph_id))

View File

@ -30,11 +30,16 @@ from dataclasses import dataclass, field
from datetime import datetime
from openai import OpenAI
from zep_cloud.client import Zep
from graphiti_core.search.search_config_recipes import (
EDGE_HYBRID_SEARCH_RRF,
NODE_HYBRID_SEARCH_RRF,
)
from ..config import Config
from ..utils.logger import get_logger
from ..utils.llm_cost import create_tracked_chat_completion
from ..utils.graphiti_client import get_graphiti, run_async
from .zep_entity_reader import EntityNode, ZepEntityReader
logger = get_logger('mirofish.oasis_profile')
@ -259,19 +264,18 @@ class OasisProfileGenerator:
"phase": "generate_profiles",
}
# --- Khởi tạo Zep client (dùng để vector search tìm thêm context) ---
# --- Cấu hình graph search (dùng để bổ sung context cho entity) ---
# 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
# trỏ đúng vào graph (group_id) của simulation hiện tại.
# zep_api_key giữ lại trong chữ ký để tương thích caller cũ, không còn dùng.
self.zep_api_key = zep_api_key
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}")
# graph search là tính năng bổ sung, chỉ bật khi Neo4j được cấu hình.
# Graphiti instance lấy lazy per-thread qua get_graphiti() khi search.
self.graph_search_enabled = bool(Config.NEO4J_URI and Config.NEO4J_PASSWORD)
if not self.graph_search_enabled:
logger.warning("Neo4j not configured — entity graph search disabled (optional feature)")
# --------------------------------------------------------------------------
# PUBLIC: generate_profile_from_entity — Entry point sinh 1 profile đơn lẻ
@ -409,8 +413,8 @@ class OasisProfileGenerator:
"""
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:
# Nếu graph search bị tắt (Neo4j chưa cấu hình) → trả về rỗng, không làm gì
if not self.graph_search_enabled:
return {"facts": [], "node_summaries": [], "context": ""}
entity_name = entity.name
@ -428,55 +432,41 @@ class OasisProfileGenerator:
# 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():
"""Tìm các fact/quan hệ qua edge search — có retry."""
def _search_with_retry(config_recipe, limit: int, kind: str):
"""Chạy Graphiti hybrid search (RRF) với retry — trả về SearchResults hoặc None.
Mỗi lần gọi nằm trong worker thread riêng (ThreadPoolExecutor), nên
get_graphiti()/run_async cấp Graphiti instance + event loop riêng cho thread đó.
"""
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, # Lấy tối đa 30 facts
scope="edges",
reranker="rrf" # Reciprocal Rank Fusion — kết hợp nhiều ranking strategies
)
graphiti = get_graphiti()
cfg = config_recipe.model_copy(deep=True)
cfg.limit = limit
return run_async(graphiti.search_(
comprehensive_query,
config=cfg,
group_ids=[self.graph_id],
))
except Exception as e:
last_exception = e
if attempt < max_retries - 1:
logger.debug(f"Zep Edge search failed on attempt {attempt + 1}: {str(e)[:80]}, retrying...")
logger.debug(f"Graph {kind} search failed on attempt {attempt + 1}: {str(e)[:80]}, retrying...")
time.sleep(delay)
delay *= 2
else:
logger.debug(f"Zep Edge search entirely failed after {max_retries} attempts: {e}")
logger.debug(f"Graph {kind} search entirely failed after {max_retries} attempts: {e}")
return None
def search_edges():
"""Tìm các fact/quan hệ qua edge search — có retry."""
return _search_with_retry(EDGE_HYBRID_SEARCH_RRF, 30, "edge")
def search_nodes():
"""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, # Lấy tối đa 20 node summaries
scope="nodes",
reranker="rrf"
)
except Exception as e:
last_exception = e
if attempt < max_retries - 1:
logger.debug(f"Zep Node search failed on attempt {attempt + 1}: {str(e)[:80]}, retrying...")
time.sleep(delay)
delay *= 2
else:
logger.debug(f"Zep Node search entirely failed after {max_retries} attempts: {e}")
return None
return _search_with_retry(NODE_HYBRID_SEARCH_RRF, 20, "node")
try:
# Chạy song song 2 search — giảm thời gian chờ từ ~2T xuống ~T
@ -513,12 +503,12 @@ class OasisProfileGenerator:
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")
logger.info(f"Graph 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})")
logger.warning(f"Graph Retrieval Time-Out ({entity_name})")
except Exception as e:
logger.warning(f"Zep Retrieval Failed ({entity_name}): {e}")
logger.warning(f"Graph Retrieval Failed ({entity_name}): {e}")
return results
@ -1205,7 +1195,7 @@ QUAN TRỌNG:
use_llm: bool = True,
progress_callback: Optional[callable] = None,
graph_id: Optional[str] = None,
parallel_count: int = 5,
parallel_count: int = 15,
realtime_output_path: Optional[str] = None,
output_platform: str = "reddit",
metadata_platform: Optional[str] = None,

View File

@ -286,8 +286,9 @@ class OntologyGenerator:
return result
# Định mức giới hạn độ dài ký tự tối đa của đoạn văn bản có thể gửi cho LLM (10 vạn chữ)
MAX_TEXT_LENGTH_FOR_LLM = 100000
# Định mức giới hạn độ dài ký tự tối đa của đoạn văn bản có thể gửi cho LLM
# (~4 ký tự/token; 400000 ký tự ≈ 100K token input, vẫn dư trong cửa sổ 262144 token của Qwen3.6-27B)
MAX_TEXT_LENGTH_FOR_LLM = 400000
def _build_user_message(
self,

View File

@ -349,7 +349,7 @@ class SimulationManager:
defined_entity_types: Optional[List[str]] = None,
use_llm_for_profiles: bool = True,
progress_callback: Optional[callable] = None,
parallel_profile_count: int = 3
parallel_profile_count: int = 15
) -> SimulationState:
"""
Giai đoạn chuẩn bị dữ liệu tạo giả lập phỏng (Tiến trình Automation 100%)

View File

@ -34,11 +34,14 @@ import time
from typing import Dict, Any, List, Optional, Set, Callable, TypeVar
from dataclasses import dataclass, field
from zep_cloud.client import Zep
# Graphiti classes import dưới alias để không đụng dataclass EntityNode định nghĩa bên dưới.
from graphiti_core.nodes import EntityNode as GraphitiEntityNode
from graphiti_core.edges import EntityEdge as GraphitiEntityEdge
from ..config import Config
from ..utils.logger import get_logger
from ..utils.zep_paging import fetch_all_nodes, fetch_all_edges
from ..utils.graphiti_client import get_graphiti, run_async
logger = get_logger('mirofish.zep_entity_reader')
@ -161,12 +164,9 @@ class ZepEntityReader:
"""
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)
# api_key giữ lại trong chữ ký để tương thích caller cũ, nhưng không còn dùng:
# Graphiti lấy cấu hình Neo4j + LLM qua get_graphiti() (lazy, per-thread).
self.api_key = api_key
# --------------------------------------------------------------------------
# PRIVATE: _call_with_retry — Retry wrapper cho các API call Zep đơn lẻ
@ -253,14 +253,13 @@ class ZepEntityReader:
"""
logger.info(f"Fetching all nodes for graph {graph_id}...")
nodes = fetch_all_nodes(self.client, graph_id)
nodes = fetch_all_nodes(get_graphiti().driver, 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
# Chuẩn hoá từ graphiti EntityNode sang Python dict thuần
nodes_data = []
for node in nodes:
nodes_data.append({
"uuid": getattr(node, 'uuid_', None) or getattr(node, 'uuid', ''),
"uuid": getattr(node, 'uuid', ''),
"name": node.name or "",
"labels": node.labels or [],
"summary": node.summary or "",
@ -286,12 +285,12 @@ class ZepEntityReader:
"""
logger.info(f"Fetching all edges for graph {graph_id}...")
edges = fetch_all_edges(self.client, graph_id)
edges = fetch_all_edges(get_graphiti().driver, graph_id)
edges_data = []
for edge in edges:
edges_data.append({
"uuid": getattr(edge, 'uuid_', None) or getattr(edge, 'uuid', ''),
"uuid": getattr(edge, 'uuid', ''),
"name": edge.name or "",
"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
@ -327,15 +326,16 @@ class ZepEntityReader:
Trả về [] nếu lỗi (không raise exception caller tự xử thiếu data).
"""
try:
driver = get_graphiti().driver
edges = self._call_with_retry(
func=lambda: self.client.graph.node.get_entity_edges(node_uuid=node_uuid),
func=lambda: run_async(GraphitiEntityEdge.get_by_node_uuid(driver, node_uuid)),
operation_name=f"Fetch node edges(node={node_uuid[:8]}...)"
)
edges_data = []
for edge in edges:
edges_data.append({
"uuid": getattr(edge, 'uuid_', None) or getattr(edge, 'uuid', ''),
"uuid": getattr(edge, 'uuid', ''),
"name": edge.name or "",
"fact": edge.fact or "",
"source_node_uuid": edge.source_node_uuid,
@ -579,9 +579,12 @@ class ZepEntityReader:
EntityNode đầy đủ hoặc None nếu không tìm thấy / lỗi
"""
try:
# Fetch node theo UUID với retry
# Fetch node theo UUID với retry.
# GraphitiEntityNode.get_by_uuid raise NodeNotFoundError nếu không có →
# được bắt bởi except phía dưới (log + trả None).
driver = get_graphiti().driver
node = self._call_with_retry(
func=lambda: self.client.graph.node.get(uuid_=entity_uuid),
func=lambda: run_async(GraphitiEntityNode.get_by_uuid(driver, entity_uuid)),
operation_name=f"Fetch node detail(uuid={entity_uuid[:8]}...)"
)
@ -629,7 +632,7 @@ class ZepEntityReader:
})
return EntityNode(
uuid=getattr(node, 'uuid_', None) or getattr(node, 'uuid', ''),
uuid=getattr(node, 'uuid', ''),
name=node.name or "",
labels=node.labels or [],
summary=node.summary or "",

View File

@ -1,6 +1,6 @@
"""
Dịch vụ cập nhật bộ nhớ đồ thị Zep
Cập nhật động các hoạt động của Agent trong phỏng lên đồ thị Zep
Dịch vụ cập nhật bộ nhớ đồ thị (Graphiti + Neo4j backend)
Cập nhật động các hoạt động của Agent trong phỏng lên đồ thị tri thức
"""
import os
@ -9,13 +9,14 @@ import threading
import json
from typing import Dict, Any, List, Optional, Callable
from dataclasses import dataclass
from datetime import datetime
from datetime import datetime, timezone
from queue import Queue, Empty
from zep_cloud.client import Zep
from graphiti_core.nodes import EpisodeType
from ..config import Config
from ..utils.logger import get_logger
from ..utils.graphiti_client import get_graphiti, run_async
logger = get_logger('mirofish.zep_graph_memory_updater')
@ -231,19 +232,18 @@ class ZepGraphMemoryUpdater:
def __init__(self, graph_id: str, api_key: Optional[str] = None):
"""
Khởi tạo trình cập nhật
Args:
graph_id: ID của đồ thị Zep
api_key: Zep API Key (tự chọn, mặc định lấy từ config)
graph_id: group_id của đồ thị Graphiti (Neo4j)
api_key: giữ lại trong chữ để tương thích caller , không còn dùng
(Graphiti lấy cấu hình Neo4j + LLM qua get_graphiti()).
"""
self.graph_id = graph_id
self.api_key = api_key or Config.ZEP_API_KEY
if not self.api_key:
raise ValueError("ZEP_API_KEY is not configured")
self.client = Zep(api_key=self.api_key)
self.api_key = api_key
# Graphiti instance lấy lazy per-thread qua get_graphiti() trong _send_batch_activities,
# không khởi tạo client ở đây để tránh chia sẻ event loop giữa các thread.
# Hàng đợi hoạt động
self._activity_queue: Queue = Queue()
@ -389,28 +389,34 @@ class ZepGraphMemoryUpdater:
def _send_batch_activities(self, activities: List[AgentActivity], platform: str):
"""
Gửi hàng loạt các hoạt động lên đồ thị Zep (Gộp chung vào một đoạn text)
Gửi hàng loạt các hoạt động lên đồ thị tri thức (Gộp chung vào một episode)
Args:
activities: Danh sách hoạt động của Agent
platform: Tên nền tảng
"""
if not activities:
return
# Gộp nhiều hoạt động vào một văn bản chung, tách nhau bởi xuống dòng
episode_texts = [activity.to_episode_text() for activity in activities]
combined_text = "\n".join(episode_texts)
# Gửi với cơ chế thử lại
for attempt in range(self.MAX_RETRIES):
try:
self.client.graph.add(
graph_id=self.graph_id,
type="text",
data=combined_text
)
# Graphiti add_episode xử lý đồng bộ (extract + ghi Neo4j xong khi await trả về).
# group_id=self.graph_id để episode ghi vào đúng partition của simulation.
graphiti = get_graphiti()
run_async(graphiti.add_episode(
name=f"sim_{platform}_round_{activities[0].round_num}",
episode_body=combined_text,
source_description=f"MiroFish simulation activity ({platform})",
reference_time=datetime.now(timezone.utc),
source=EpisodeType.text,
group_id=self.graph_id,
))
self._total_sent += 1
self._total_items_sent += len(activities)
display_name = self._get_platform_display_name(platform)
@ -420,10 +426,10 @@ class ZepGraphMemoryUpdater:
except Exception as e:
if attempt < self.MAX_RETRIES - 1:
logger.warning(f"Failed to send batch to Zep (attempt {attempt + 1}/{self.MAX_RETRIES}): {e}")
logger.warning(f"Failed to send batch to graph (attempt {attempt + 1}/{self.MAX_RETRIES}): {e}")
time.sleep(self.RETRY_DELAY * (attempt + 1))
else:
logger.error(f"Failed to send batch to Zep after {self.MAX_RETRIES} attempts: {e}")
logger.error(f"Failed to send batch to graph after {self.MAX_RETRIES} attempts: {e}")
self._failed_count += 1
def _flush_remaining(self):

View File

@ -1,5 +1,5 @@
"""
Dịch vụ cung cấp các công cụ tìm kiếm trên nền tảng Zep Cloud.
Dịch vụ cung cấp các công cụ tìm kiếm trên đồ thị tri thức (Graphiti + Neo4j backend).
Đóng gói các công cụ tìm kiếm đồ thị (graph search), đọc thông tin node, truy vấn cạnh (edge), v.v., để Report Agent sử dụng.
Các công cụ tìm kiếm cốt lõi (sau khi tối ưu hóa):
@ -13,12 +13,17 @@ import json
from typing import Dict, Any, List, Optional
from dataclasses import dataclass, field
from zep_cloud.client import Zep
from graphiti_core.nodes import EntityNode as GraphitiEntityNode
from graphiti_core.search.search_config_recipes import (
EDGE_HYBRID_SEARCH_RRF,
NODE_HYBRID_SEARCH_RRF,
)
from ..config import Config
from ..utils.logger import get_logger
from ..utils.llm_client import LLMClient
from ..utils.zep_paging import fetch_all_nodes, fetch_all_edges
from ..utils.graphiti_client import get_graphiti, run_async
logger = get_logger('mirofish.zep_tools')
@ -423,11 +428,9 @@ class ZepToolsService:
RETRY_DELAY = 2.0
def __init__(self, api_key: Optional[str] = None, llm_client: Optional[LLMClient] = None):
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)
# api_key giữ lại trong chữ ký để tương thích caller cũ, không còn dùng:
# Graphiti lấy cấu hình Neo4j + LLM qua get_graphiti() (lazy, per-thread).
self.api_key = api_key
# LLM client được sử dụng bởi InsightForge để sinh ra các sub-queries
self._llm_client = llm_client
logger.info("ZepToolsService initialized successfully")
@ -487,42 +490,36 @@ class ZepToolsService:
SearchResult: Đối tượng chứa kết quả tìm kiếm đã phân tích
"""
logger.info(f"Graph search: graph_id={graph_id}, query={query[:50]}...")
# Thử sử dụng API Zep Cloud Search
# Thử dùng Graphiti hybrid search (semantic + BM25 + RRF rerank).
try:
search_results = self._call_with_retry(
func=lambda: self.client.graph.search(
graph_id=graph_id,
query=query,
limit=limit,
scope=scope,
reranker="cross_encoder"
),
func=lambda: self._graphiti_search(graph_id, query, limit, scope),
operation_name=f"Graph Search(graph={graph_id})"
)
facts = []
edges = []
nodes = []
# Phân tích kết quả tìm kiếm cạnh (edges/relationships)
if hasattr(search_results, 'edges') and search_results.edges:
for edge in search_results.edges:
if hasattr(edge, 'fact') and edge.fact:
facts.append(edge.fact)
edges.append({
"uuid": getattr(edge, 'uuid_', None) or getattr(edge, 'uuid', ''),
"uuid": getattr(edge, 'uuid', ''),
"name": getattr(edge, 'name', ''),
"fact": getattr(edge, 'fact', ''),
"source_node_uuid": getattr(edge, 'source_node_uuid', ''),
"target_node_uuid": getattr(edge, 'target_node_uuid', ''),
})
# Phân tích kết quả tìm kiếm thực thể (nodes)
if hasattr(search_results, 'nodes') and search_results.nodes:
for node in search_results.nodes:
nodes.append({
"uuid": getattr(node, 'uuid_', None) or getattr(node, 'uuid', ''),
"uuid": getattr(node, 'uuid', ''),
"name": getattr(node, 'name', ''),
"labels": getattr(node, 'labels', []),
"summary": getattr(node, 'summary', ''),
@ -530,9 +527,9 @@ class ZepToolsService:
# Phần tóm tắt (summary) của node cũng được coi là một fact
if hasattr(node, 'summary') and node.summary:
facts.append(f"[{node.name}]: {node.summary}")
logger.info(f"Search completed: Found {len(facts)} related facts")
return SearchResult(
facts=facts,
edges=edges,
@ -540,11 +537,40 @@ class ZepToolsService:
query=query,
total_count=len(facts)
)
except Exception as e:
logger.warning(f"Zep Search API failed, gracefully degrading to local search: {str(e)}")
logger.warning(f"Graph Search failed, gracefully degrading to local search: {str(e)}")
# Hạ cấp: Sử dụng tìm kiếm theo từ khóa cục bộ
return self._local_search(graph_id, query, limit, scope)
def _graphiti_search(self, graph_id: str, query: str, limit: int, scope: str):
"""Gọi Graphiti hybrid search và gộp kết quả node/edge.
Map scope (Zep) search recipe (Graphiti):
- "edges" EDGE_HYBRID_SEARCH_RRF (chỉ trả edges/facts)
- "nodes" NODE_HYBRID_SEARCH_RRF (chỉ trả nodes)
- "both" chạy cả hai rồi gộp lại
Trả về SearchResults ( .edges .nodes) để caller xử đồng nhất.
"""
from graphiti_core.search.search_config import SearchResults
graphiti = get_graphiti()
group_ids = [graph_id]
async def _run():
results: List[SearchResults] = []
if scope in ("edges", "both"):
cfg = EDGE_HYBRID_SEARCH_RRF.model_copy(deep=True)
cfg.limit = limit
results.append(await graphiti.search_(query, config=cfg, group_ids=group_ids))
if scope in ("nodes", "both"):
cfg = NODE_HYBRID_SEARCH_RRF.model_copy(deep=True)
cfg.limit = limit
results.append(await graphiti.search_(query, config=cfg, group_ids=group_ids))
return SearchResults.merge(results) if results else SearchResults()
return run_async(_run())
def _local_search(
self,
@ -662,11 +688,11 @@ class ZepToolsService:
"""
logger.info(f"Fetching all nodes for graph {graph_id}...")
nodes = fetch_all_nodes(self.client, graph_id)
nodes = fetch_all_nodes(get_graphiti().driver, graph_id)
result = []
for node in nodes:
node_uuid = getattr(node, 'uuid_', None) or getattr(node, 'uuid', None) or ""
node_uuid = getattr(node, 'uuid', None) or ""
result.append(NodeInfo(
uuid=str(node_uuid) if node_uuid else "",
name=node.name or "",
@ -691,11 +717,11 @@ class ZepToolsService:
"""
logger.info(f"Fetching all edges for graph {graph_id}...")
edges = fetch_all_edges(self.client, graph_id)
edges = fetch_all_edges(get_graphiti().driver, graph_id)
result = []
for edge in edges:
edge_uuid = getattr(edge, 'uuid_', None) or getattr(edge, 'uuid', None) or ""
edge_uuid = getattr(edge, 'uuid', None) or ""
edge_info = EdgeInfo(
uuid=str(edge_uuid) if edge_uuid else "",
name=edge.name or "",
@ -704,12 +730,15 @@ class ZepToolsService:
target_node_uuid=edge.target_node_uuid or ""
)
# Bổ sung thông tin thời gian hợp lệ (temporal info)
# Bổ sung thông tin thời gian hợp lệ (temporal info).
# Graphiti trả về datetime → ép sang str cho đồng nhất với EdgeInfo (Optional[str]).
if include_temporal:
edge_info.created_at = getattr(edge, 'created_at', None)
edge_info.valid_at = getattr(edge, 'valid_at', None)
edge_info.invalid_at = getattr(edge, 'invalid_at', None)
edge_info.expired_at = getattr(edge, 'expired_at', None)
def _ts(value):
return str(value) if value is not None else None
edge_info.created_at = _ts(getattr(edge, 'created_at', None))
edge_info.valid_at = _ts(getattr(edge, 'valid_at', None))
edge_info.invalid_at = _ts(getattr(edge, 'invalid_at', None))
edge_info.expired_at = _ts(getattr(edge, 'expired_at', None))
result.append(edge_info)
@ -729,16 +758,19 @@ class ZepToolsService:
logger.info(f"Fetching node detail: {node_uuid[:8]}...")
try:
# GraphitiEntityNode.get_by_uuid raise NodeNotFoundError nếu không có →
# được bắt bởi except phía dưới (log + trả None).
driver = get_graphiti().driver
node = self._call_with_retry(
func=lambda: self.client.graph.node.get(uuid_=node_uuid),
func=lambda: run_async(GraphitiEntityNode.get_by_uuid(driver, node_uuid)),
operation_name=f"Fetching node detail (uuid={node_uuid[:8]}...)"
)
if not node:
return None
return NodeInfo(
uuid=getattr(node, 'uuid_', None) or getattr(node, 'uuid', ''),
uuid=getattr(node, 'uuid', ''),
name=node.name or "",
labels=node.labels or [],
summary=node.summary or "",
@ -1818,10 +1850,11 @@ Ràng buộc định dạng (BẮT BUỘC tuân thủ):
- Sử dụng dấu ngoặc kép kiểu Việt Nam khi trích dẫn nguyên văn lời của người được phỏng vấn.
- thể sử dụng dấu **in đậm** cho các từ khóa, nhưng không sử dụng bất kỳ pháp Markdown nào khác."""
interview_content = "\n\n".join(interview_texts)
user_prompt = f"""Chủ đề phỏng vấn: {interview_requirement}
Nội dung phỏng vấn:
{"\n\n".join(interview_texts)}
{interview_content}
Hãy tạo bản tóm tắt phỏng vấn."""

View File

@ -41,7 +41,7 @@ class LLMClient:
self,
messages: List[Dict[str, str]],
temperature: float = 0.7,
max_tokens: int = 16000,
max_tokens: int = 32000,
response_format: Optional[Dict] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> str:
@ -88,7 +88,7 @@ class LLMClient:
self,
messages: List[Dict[str, str]],
temperature: float = 0.3,
max_tokens: int = 50000,
max_tokens: int = 32000,
metadata: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""

View File

@ -17,6 +17,7 @@ from graphiti_core.edges import EntityEdge
from graphiti_core.errors import GroupsEdgesNotFoundError
from graphiti_core.nodes import EntityNode
from .graphiti_client import run_async
from .logger import get_logger
logger = get_logger('mirofish.graph_paging')
@ -142,7 +143,9 @@ def fetch_all_nodes(
retry_delay: float = _DEFAULT_RETRY_DELAY,
) -> list[Any]:
"""Lấy toàn bộ EntityNode theo group_id, tối đa max_items (mặc định 2000)."""
return asyncio.run(_fetch_all_nodes_async(driver, graph_id, page_size, max_items, max_retries, retry_delay))
# run_async (loop thread-local) thay vì asyncio.run (loop mới) để Neo4j driver
# luôn chạy trên cùng event loop đã gắn — tránh "Future attached to a different loop".
return run_async(_fetch_all_nodes_async(driver, graph_id, page_size, max_items, max_retries, retry_delay))
def fetch_all_edges(
@ -153,4 +156,4 @@ def fetch_all_edges(
retry_delay: float = _DEFAULT_RETRY_DELAY,
) -> list[Any]:
"""Lấy toàn bộ EntityEdge theo group_id."""
return asyncio.run(_fetch_all_edges_async(driver, graph_id, page_size, max_retries, retry_delay))
return run_async(_fetch_all_edges_async(driver, graph_id, page_size, max_retries, retry_delay))

View File

@ -1125,7 +1125,7 @@ def create_model(config: Dict[str, Any], use_boost: bool = False):
)
def configure_agent_memory_limits(agent_graph, token_limit=150000, message_window_size=25):
def configure_agent_memory_limits(agent_graph, token_limit=150000, message_window_size=50):
"""
Cấu hình giới hạn memory cho tất cả agent trong agent_graph.
@ -1337,7 +1337,7 @@ async def run_twitter_simulation(
result.env = oasis.make(
agent_graph=result.agent_graph,
platform=twitter_platform,
semaphore=20,
semaphore=30,
)
await result.env.reset()
@ -1532,7 +1532,7 @@ async def run_reddit_simulation(
agent_graph=result.agent_graph,
platform=oasis.DefaultPlatformType.REDDIT,
database_path=db_path,
semaphore=20,
semaphore=30,
)
await result.env.reset()

View File

@ -0,0 +1,126 @@
# Xem Knowledge Graph trên Neo4j Browser
Hướng dẫn mở Neo4j Browser và xem graph (entity + quan hệ) theo từng `group_id`.
---
## 1. Mở Neo4j Browser
Vào trình duyệt:
**http://localhost:7474/browser/**
Đăng nhập (lấy từ `.env` của project):
| Trường | Giá trị |
|---|---|
| Connect URL | `bolt://localhost:7687` |
| Username | `neo4j` |
| Password | `team10diem` |
> Sau khi connect, gõ Cypher vào ô lệnh trên cùng → bấm ▶ hoặc `Ctrl+Enter` để chạy.
---
## 2. `group_id` là gì — và tại sao BẮT BUỘC dùng
Mỗi lần build graph tạo ra một **`group_id`** riêng (= `graph_id`, dạng `mirofish_<hex>`).
Tất cả node/edge của một graph được gắn cùng `group_id` đó.
⚠️ **Luôn lọc theo `group_id` trong MỌI query.** Nếu không, Neo4j trả về node của **tất cả graph** trộn lẫn → rối và sai.
### Liệt kê các `group_id` đang có
```cypher
MATCH (n:Entity)
RETURN n.group_id AS graph_id, count(*) AS so_entity
ORDER BY so_entity DESC
```
Các graph hiện có (ví dụ — của bạn có thể khác):
| group_id | số entity |
|---|---|
| `mirofish_47869debf1e64df1` | 53 |
| `mirofish_ffef92194e574283` | 31 |
| `mirofish_8770c4041fb141d3` | 30 |
| `mirofish_ea729b57027c4ceb` | 23 |
> 💡 Copy `group_id` muốn xem, rồi thay vào `$GID` trong các query bên dưới.
> `graph_id` cũng được in ra khi build graph xong (`"graph_id": "mirofish_..."`).
---
## 3. Hai kiểu xem chính
### Kiểu A — Xem TRỰC QUAN (entity + quan hệ, dạng đồ thị kéo thả)
Đổi `group_id` cho đúng graph bạn muốn:
```cypher
MATCH (n:Entity {group_id: 'mirofish_8770c4041fb141d3'})-[r:RELATES_TO]->(m:Entity)
RETURN n, r, m
```
→ Neo4j Browser hiện các **node tròn (entity)** nối với nhau bằng **mũi tên (quan hệ)**, kéo thả được.
Bấm vào 1 node/edge để xem chi tiết (name, fact, summary...).
### Kiểu B — Xem dạng BẢNG (tên + loại + tóm tắt entity)
```cypher
MATCH (n:Entity {group_id: 'mirofish_8770c4041fb141d3'})
RETURN n.name AS ten, labels(n) AS loai, n.summary AS tom_tat
```
→ Hiện bảng: tên thực thể | loại (entity type) | tóm tắt.
(Nếu Browser mở ở chế độ Graph, bấm icon **Table** bên trái kết quả để xem dạng bảng.)
---
## 4. Đổi graph khác để xem
Chỉ cần thay chuỗi `group_id` trong query. Ví dụ xem graph 53-entity:
```cypher
MATCH (n:Entity {group_id: 'mirofish_47869debf1e64df1'})-[r:RELATES_TO]->(m:Entity)
RETURN n, r, m
```
---
## 5. Một số query hữu ích khác (tùy chọn)
**Xem cả chunk (Episodic) + entity mà chunk đó trích ra:**
```cypher
MATCH (e:Episodic {group_id: 'mirofish_8770c4041fb141d3'})-[r:MENTIONS]->(n:Entity)
RETURN e, r, n
```
**Xem TẤT CẢ node + mọi quan hệ của 1 graph (graph đầy đủ):**
```cypher
MATCH (n {group_id: 'mirofish_8770c4041fb141d3'})-[r]->(m {group_id: 'mirofish_8770c4041fb141d3'})
RETURN n, r, m
```
**Xem các fact (quan hệ) dạng bảng — nguồn → fact → đích:**
```cypher
MATCH (n:Entity {group_id: 'mirofish_8770c4041fb141d3'})-[r:RELATES_TO]->(m:Entity)
RETURN n.name AS tu, r.fact AS quan_he, m.name AS den
```
**Chỉ xem fact CÒN hiệu lực (bỏ fact đã bị vô hiệu hóa / expired):**
```cypher
MATCH (n:Entity {group_id: 'mirofish_8770c4041fb141d3'})-[r:RELATES_TO]->(m:Entity)
WHERE r.expired_at IS NULL
RETURN n.name AS tu, r.fact AS quan_he, m.name AS den
```
**Giới hạn cho nhẹ (graph lớn):** thêm `LIMIT 50` vào cuối query.
---
## Mẹo
- Loại node: `Entity` (thực thể có tên), `Episodic` (chunk text gốc), quan hệ chính là `RELATES_TO`.
- Trong Browser, kết quả có 2 nút chuyển: **Graph view** (hình tròn) và **Table view** (bảng) — ở cạnh trái khung kết quả.
- Mỗi `:RELATES_TO` có field `fact` (câu mô tả quan hệ), `valid_at` / `invalid_at` / `expired_at` (thông tin thời gian — temporal graph).

View File

@ -10,7 +10,7 @@ full_content = False
# start_time = 2026-02-09
# end_time = 2026-02-12
start_time = 2026-04-13
end_time = 2026-04-14
end_time = 2026-04-16
# ─── Simulation settings ──────────────────────────────────────────────────────
# What the simulation should analyse / predict (sent to the LLM)