feat: migrate knowledge graph from Zep Cloud to Graphiti + local Neo4j

Replaces the paid, rate-limited Zep Cloud service with Graphiti (graphiti-core
0.11.6) backed by a local Neo4j instance — free, unlimited, and self-hosted.

Key changes:
- Add GraphitiAdapter: drop-in Zep-compatible wrapper around Graphiti with a
  persistent event-loop thread to avoid asyncio/Neo4j driver conflicts
- Switch LLM client to native GeminiClient + GeminiEmbedder (text-embedding-004
  fails on Gemini compat endpoint; use google-genai SDK directly)
- Add _GeminiReranker passthrough replacing OpenAIRerankerClient (which
  hardcodes gpt-4.1-nano and uses logprobs unsupported by Gemini)
- Fix Cypher queries: use s.uuid/t.uuid for edge source/target instead of
  r.source_node_uuid (null property in Graphiti's schema)
- Add ontology-based entity type classifier (_classify_entity_type) so nodes
  get colored by type in the D3 graph visualization instead of all being Entity
- Apply classifier in ZepEntityReader so filter_defined_entities finds entities
  (previously 0 personas loaded because all labels were ['Entity'])
- Add startup recovery: auto-mark graph_building projects as graph_completed
  on backend restart if Neo4j already has their data
- Add resume capability to graph build: skip already-processed episodes after
  a restart (断点续传)
- Add non-blocking graph data cache with background refresh in graph.py
- Add EMBEDDING_MODEL config (default: gemini-embedding-001 for Gemini users)
- Add CLAUDE.md with project architecture and dev commands

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Abhishek Yadav 2026-03-22 01:30:28 +05:30
parent 1536a79334
commit 28827067c0
13 changed files with 941 additions and 218 deletions

86
CLAUDE.md Normal file
View File

@ -0,0 +1,86 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
MiroFish is a multi-agent swarm intelligence prediction engine. It builds knowledge graphs from seed data, simulates thousands of AI agents interacting on virtual Twitter/Reddit platforms (via CAMEL-OASIS), and generates analytical reports — all to predict outcomes of real-world scenarios.
## Commands
### Setup
```bash
npm run setup:all # Install all dependencies (frontend + backend)
npm run setup # Frontend npm install only
npm run setup:backend # Backend: uv sync (Python deps)
```
### Development
```bash
npm run dev # Run backend + frontend concurrently
npm run backend # Backend only: Flask on port 5001
npm run frontend # Frontend only: Vite on port 3000
```
### Build
```bash
npm run build # Build frontend (Vite)
```
### Backend (Python)
```bash
cd backend && uv run python run.py # Start Flask server
cd backend && uv run python -m pytest # Run tests (if any)
```
### Docker
```bash
docker-compose up # Full stack via Docker
```
## Architecture
### Stack
- **Backend**: Python ≥3.11 Flask 3.0, managed by `uv`
- **Frontend**: Vue 3 + Vite, port 3000; proxies `/api` → port 5001
- **LLM**: OpenAI SDK-compatible (default: Qwen via `dashscope`; also works with GLM, OpenAI)
- **Memory/Graph**: Zep Cloud (knowledge graph for entity storage and retrieval)
- **Simulation**: CAMEL-OASIS (multi-agent Twitter + Reddit simulation)
- **Visualization**: D3.js
### Required Environment Variables
Copy `.env.example` to `.env`:
```
LLM_API_KEY # Required
LLM_BASE_URL # Default: https://dashscope.aliyuncs.com/compatible-mode/v1
LLM_MODEL_NAME # Default: qwen-plus
ZEP_API_KEY # Required (Zep Cloud)
```
### 5-Step Pipeline
The core workflow is a sequential async pipeline:
1. **Graph Build** — Upload files → LLM extracts ontology → Zep Cloud builds knowledge graph
2. **Env Setup** — Read Zep entities → Generate OASIS agent profiles (AI personalities)
3. **Simulation** — CAMEL-OASIS runs agents on dual platforms (Twitter + Reddit) in parallel
4. **Report** — ReportAgent (ReACT loop) queries graph with tools: `SearchResult`, `InsightForge`, `Panorama`, `Interview`
5. **Interaction** — Chat with simulated agents or the ReportAgent
### Backend Structure (`backend/app/`)
- `api/` — Flask blueprints: `graph_bp`, `simulation_bp`, `report_bp`
- `services/` — Core logic: graph building, simulation runner, report agent, Zep tools
- `models/``Project` and `Task` state objects (in-memory, JSON-serializable)
- `utils/` — LLM client wrapper, file parser, retry logic, Zep pagination
- `config/config.py` — All configuration (LLM, Zep, chunking, simulation params)
Long-running operations (ontology generation, graph build, profile generation, report generation) run as background tasks tracked via `Task` objects with progress polling.
### Frontend Structure (`frontend/src/`)
- `views/` — Page components mapped to routes; `Process.vue` is the main 50KB workflow orchestrator
- `components/``Step1-5` step components + `GraphPanel.vue` (D3 graph visualization)
- `api/` — Axios services (`graph.js`, `simulation.js`, `report.js`) with 5-min timeout and exponential retry
### Key Implementation Details
- Reasoning model outputs (e.g., MiniMax/GLM with `<think>` tags or markdown code fences) are stripped before processing — see recent fix in commit `985f89f`
- Simulation state is managed in `SimulationManager`; IPC between processes via `simulation_ipc.py`
- Interview/chat with agents uses prefix injection to suppress tool calls in responses
- Default simulation: max 10 rounds, Twitter actions include CREATE_POST/LIKE/REPOST/FOLLOW/QUOTE/DO_NOTHING; Reddit adds CREATE_COMMENT/DISLIKE

View File

@ -73,8 +73,36 @@ def create_app(config_class=Config):
def health():
return {'status': 'ok', 'service': 'MiroFish Backend'}
# On startup: recover any projects stuck in graph_building (task was killed by restart)
if should_log_startup:
_recover_stuck_projects()
if should_log_startup:
logger.info("MiroFish Backend 启动完成")
return app
def _recover_stuck_projects():
"""Mark graph_building projects as completed if Neo4j already has their data."""
from .models.project import ProjectManager, ProjectStatus
from .utils.logger import get_logger as _get_logger
_log = _get_logger('mirofish.startup')
try:
for p in ProjectManager.list_projects():
if p.status == ProjectStatus.GRAPH_BUILDING and p.graph_id:
from .services.graphiti_adapter import _get_graphiti, _run, _neo4j_query
g = _get_graphiti()
r = _run(_neo4j_query(g,
'MATCH (n:Entity {group_id: $gid}) RETURN count(n) AS n',
{'gid': p.graph_id}
))
node_count = int(r[0]['n']) if r else 0
if node_count > 0:
p.status = ProjectStatus.GRAPH_COMPLETED
p.graph_build_task_id = None
ProjectManager.save_project(p)
_log.info(f"Recovered stuck project {p.project_id}: {node_count} nodes found, marked graph_completed")
except Exception as e:
_get_logger('mirofish.startup').warning(f"Startup recovery failed: {e}")

View File

@ -4,6 +4,7 @@
"""
import os
import time
import traceback
import threading
from flask import request, jsonify
@ -18,6 +19,12 @@ from ..utils.logger import get_logger
from ..models.task import TaskManager, TaskStatus
from ..models.project import ProjectManager, ProjectStatus
# In-memory cache for graph data to avoid hammering Zep's rate-limited API.
# Stale cache is served instantly on 429; a background thread refreshes it.
_graph_data_cache: dict = {} # graph_id -> {"data": ..., "ts": float}
_graph_refresh_locks: dict = {} # graph_id -> threading.Lock (one refresh at a time)
_GRAPH_CACHE_TTL = 300 # seconds before triggering a background refresh
# 获取日志器
logger = get_logger('mirofish.api')
@ -284,8 +291,8 @@ def build_graph():
# 检查配置
errors = []
if not Config.ZEP_API_KEY:
errors.append("ZEP_API_KEY未配置")
if not Config.NEO4J_PASSWORD:
errors.append("NEO4J未配置")
if errors:
logger.error(f"配置错误: {errors}")
return jsonify({
@ -382,7 +389,7 @@ def build_graph():
)
# 创建图谱构建服务
builder = GraphBuilderService(api_key=Config.ZEP_API_KEY)
builder = GraphBuilderService()
# 分块
task_manager.update_task(
@ -426,17 +433,30 @@ def build_graph():
progress=progress
)
task_manager.update_task(
task_id,
message=f"开始添加 {total_chunks} 个文本块...",
progress=15
)
# Count already-processed episodes to resume after a restart
from app.services.graphiti_adapter import _get_graphiti, _run, _neo4j_query
try:
g = _get_graphiti()
ep_count = _run(_neo4j_query(g,
'MATCH (e:Episodic {group_id: $gid}) RETURN count(e) AS n',
{'gid': graph_id}
))
already_done = int(ep_count[0]['n']) if ep_count else 0
except Exception:
already_done = 0
skip_chunks = already_done
remaining = total_chunks - skip_chunks
msg_start = (f"断点续传:跳过 {skip_chunks} 个已处理块,继续处理 {remaining} 块..."
if skip_chunks > 0 else f"开始添加 {total_chunks} 个文本块...")
task_manager.update_task(task_id, message=msg_start, progress=15)
episode_uuids = builder.add_text_batches(
graph_id,
graph_id,
chunks,
batch_size=3,
progress_callback=add_progress_callback
progress_callback=add_progress_callback,
skip_chunks=skip_chunks,
)
# 等待Zep处理完成查询每个episode的processed状态
@ -561,32 +581,59 @@ def list_tasks():
# ============== 图谱数据接口 ==============
def _refresh_graph_cache(graph_id: str):
"""Background thread: fetch graph data from Neo4j and update cache."""
lock = _graph_refresh_locks.setdefault(graph_id, threading.Lock())
if not lock.acquire(blocking=False):
return # another refresh already in progress
try:
# Look up ontology from the project that owns this graph_id
ontology = None
for project in ProjectManager.list_projects():
if project.graph_id == graph_id and project.ontology:
ontology = project.ontology
break
builder = GraphBuilderService()
graph_data = builder.get_graph_data(graph_id, ontology=ontology)
_graph_data_cache[graph_id] = {"data": graph_data, "ts": time.time()}
logger.info(f"Graph cache refreshed for {graph_id}")
except Exception as e:
logger.warning(f"Background graph cache refresh failed for {graph_id}: {str(e)[:100]}")
finally:
lock.release()
@graph_bp.route('/data/<graph_id>', methods=['GET'])
def get_graph_data(graph_id: str):
"""
获取图谱数据节点和边
获取图谱数据节点和边
- 有缓存且未过期直接返回缓存不调用 Zep
- 有缓存但已过期立即返回旧缓存后台异步刷新
- 无缓存后台线程拉取返回 202 让前端稍后重试
"""
try:
if not Config.ZEP_API_KEY:
return jsonify({
"success": False,
"error": "ZEP_API_KEY未配置"
}), 500
builder = GraphBuilderService(api_key=Config.ZEP_API_KEY)
graph_data = builder.get_graph_data(graph_id)
return jsonify({
"success": True,
"data": graph_data
})
except Exception as e:
return jsonify({
"success": False,
"error": str(e),
"traceback": traceback.format_exc()
}), 500
if not Config.NEO4J_PASSWORD:
return jsonify({"success": False, "error": "NEO4J未配置"}), 500
cached = _graph_data_cache.get(graph_id)
age = time.time() - cached["ts"] if cached else None
if cached and age < _GRAPH_CACHE_TTL:
# Fresh cache — return immediately
return jsonify({"success": True, "data": cached["data"], "cached": True})
if cached:
# Stale cache — serve it immediately, refresh in background
threading.Thread(target=_refresh_graph_cache, args=(graph_id,), daemon=True).start()
return jsonify({"success": True, "data": cached["data"], "cached": True, "stale": True})
# No cache at all — kick off background fetch, tell frontend to retry
threading.Thread(target=_refresh_graph_cache, args=(graph_id,), daemon=True).start()
return jsonify({
"success": False,
"error": "Graph data is loading, please retry in a moment.",
"retry": True
}), 202
@graph_bp.route('/delete/<graph_id>', methods=['DELETE'])
@ -595,13 +642,13 @@ def delete_graph(graph_id: str):
删除Zep图谱
"""
try:
if not Config.ZEP_API_KEY:
if not Config.NEO4J_PASSWORD:
return jsonify({
"success": False,
"error": "ZEP_API_KEY未配置"
"error": "NEO4J未配置"
}), 500
builder = GraphBuilderService(api_key=Config.ZEP_API_KEY)
builder = GraphBuilderService()
builder.delete_graph(graph_id)
return jsonify({

View File

@ -32,8 +32,15 @@ class Config:
LLM_BASE_URL = os.environ.get('LLM_BASE_URL', 'https://api.openai.com/v1')
LLM_MODEL_NAME = os.environ.get('LLM_MODEL_NAME', 'gpt-4o-mini')
# Zep配置
ZEP_API_KEY = os.environ.get('ZEP_API_KEY')
# Neo4j + Graphiti配置替代 Zep Cloud
NEO4J_URI = os.environ.get('NEO4J_URI', 'bolt://localhost:7687')
NEO4J_USER = os.environ.get('NEO4J_USER', 'neo4j')
NEO4J_PASSWORD = os.environ.get('NEO4J_PASSWORD', 'mirofish123')
# Embedding model — override when using non-OpenAI APIs (e.g. Gemini: text-embedding-004)
EMBEDDING_MODEL = os.environ.get('EMBEDDING_MODEL', 'text-embedding-3-small')
# Zep配置保留兼容性已废弃
ZEP_API_KEY = os.environ.get('ZEP_API_KEY', '')
# 文件上传配置
MAX_CONTENT_LENGTH = 50 * 1024 * 1024 # 50MB
@ -69,7 +76,7 @@ class Config:
errors = []
if not cls.LLM_API_KEY:
errors.append("LLM_API_KEY 未配置")
if not cls.ZEP_API_KEY:
errors.append("ZEP_API_KEY 未配置")
if not cls.NEO4J_PASSWORD:
errors.append("NEO4J_PASSWORD 未配置")
return errors

View File

@ -10,8 +10,7 @@ import threading
from typing import Dict, Any, List, Optional, Callable
from dataclasses import dataclass
from zep_cloud.client import Zep
from zep_cloud import EpisodeData, EntityEdgeSourceTarget
from .graphiti_adapter import GraphitiAdapter
from ..config import Config
from ..models.task import TaskManager, TaskStatus
@ -19,6 +18,54 @@ from ..utils.zep_paging import fetch_all_nodes, fetch_all_edges
from .text_processor import TextProcessor
def _classify_entity_type(name: str, summary: str, ontology: Optional[Dict]) -> str:
"""
Classify an entity into an ontology type using keyword matching
against entity type names, descriptions, and examples.
Falls back to 'Entity' if no ontology or no match found.
"""
if not ontology:
return "Entity"
entity_types = ontology.get("entity_types", [])
if not entity_types:
return "Entity"
name_lower = (name or "").lower()
summary_lower = (summary or "").lower()
search_text = f"{name_lower} {summary_lower}"
best_type = "Entity"
best_score = 0
for et in entity_types:
score = 0
type_name = et.get("name", "")
type_name_lower = type_name.lower()
# Exact name match in type name
if type_name_lower in name_lower:
score += 10
# Check examples list
for example in et.get("examples", []):
if example.lower() in search_text:
score += 8
elif name_lower in example.lower():
score += 6
# Check description keywords
desc_words = (et.get("description", "")).lower().split()
for word in desc_words:
if len(word) > 4 and word in search_text:
score += 1
if score > best_score:
best_score = score
best_type = type_name
return best_type if best_score > 0 else "Entity"
@dataclass
class GraphInfo:
"""图谱信息"""
@ -43,11 +90,7 @@ class GraphBuilderService:
"""
def __init__(self, api_key: Optional[str] = None):
self.api_key = api_key or Config.ZEP_API_KEY
if not self.api_key:
raise ValueError("ZEP_API_KEY 未配置")
self.client = Zep(api_key=self.api_key)
self.client = GraphitiAdapter()
self.task_manager = TaskManager()
def build_graph_async(
@ -197,106 +240,27 @@ class GraphBuilderService:
return graph_id
def set_ontology(self, graph_id: str, ontology: Dict[str, Any]):
"""设置图谱本体(公开方法)"""
import warnings
from typing import Optional
from pydantic import Field
from zep_cloud.external_clients.ontology import EntityModel, EntityText, EdgeModel
# 抑制 Pydantic v2 关于 Field(default=None) 的警告
# 这是 Zep SDK 要求的用法,警告来自动态类创建,可以安全忽略
warnings.filterwarnings('ignore', category=UserWarning, module='pydantic')
# Zep 保留名称,不能作为属性名
RESERVED_NAMES = {'uuid', 'name', 'group_id', 'name_embedding', 'summary', 'created_at'}
def safe_attr_name(attr_name: str) -> str:
"""将保留名称转换为安全名称"""
if attr_name.lower() in RESERVED_NAMES:
return f"entity_{attr_name}"
return attr_name
# 动态创建实体类型
entity_types = {}
for entity_def in ontology.get("entity_types", []):
name = entity_def["name"]
description = entity_def.get("description", f"A {name} entity.")
# 创建属性字典和类型注解Pydantic v2 需要)
attrs = {"__doc__": description}
annotations = {}
for attr_def in entity_def.get("attributes", []):
attr_name = safe_attr_name(attr_def["name"]) # 使用安全名称
attr_desc = attr_def.get("description", attr_name)
# Zep API 需要 Field 的 description这是必需的
attrs[attr_name] = Field(description=attr_desc, default=None)
annotations[attr_name] = Optional[EntityText] # 类型注解
attrs["__annotations__"] = annotations
# 动态创建类
entity_class = type(name, (EntityModel,), attrs)
entity_class.__doc__ = description
entity_types[name] = entity_class
# 动态创建边类型
edge_definitions = {}
for edge_def in ontology.get("edge_types", []):
name = edge_def["name"]
description = edge_def.get("description", f"A {name} relationship.")
# 创建属性字典和类型注解
attrs = {"__doc__": description}
annotations = {}
for attr_def in edge_def.get("attributes", []):
attr_name = safe_attr_name(attr_def["name"]) # 使用安全名称
attr_desc = attr_def.get("description", attr_name)
# Zep API 需要 Field 的 description这是必需的
attrs[attr_name] = Field(description=attr_desc, default=None)
annotations[attr_name] = Optional[str] # 边属性用str类型
attrs["__annotations__"] = annotations
# 动态创建类
class_name = ''.join(word.capitalize() for word in name.split('_'))
edge_class = type(class_name, (EdgeModel,), attrs)
edge_class.__doc__ = description
# 构建source_targets
source_targets = []
for st in edge_def.get("source_targets", []):
source_targets.append(
EntityEdgeSourceTarget(
source=st.get("source", "Entity"),
target=st.get("target", "Entity")
)
)
if source_targets:
edge_definitions[name] = (edge_class, source_targets)
# 调用Zep API设置本体
if entity_types or edge_definitions:
self.client.graph.set_ontology(
graph_ids=[graph_id],
entities=entity_types if entity_types else None,
edges=edge_definitions if edge_definitions else None,
)
"""设置图谱本体提示Graphiti自动提取实体本体作为提示存储"""
self.client.graph.set_ontology(
graph_ids=[graph_id],
entities=ontology.get("entity_types"),
edges=ontology.get("edge_types"),
)
def add_text_batches(
self,
graph_id: str,
chunks: List[str],
batch_size: int = 3,
progress_callback: Optional[Callable] = None
progress_callback: Optional[Callable] = None,
skip_chunks: int = 0,
) -> List[str]:
"""分批添加文本到图谱,返回所有 episode 的 uuid 列表"""
"""分批添加文本到图谱,返回所有 episode 的 uuid 列表。
skip_chunks: 跳过已处理的块数用于断点续传"""
episode_uuids = []
total_chunks = len(chunks)
for i in range(0, total_chunks, batch_size):
for i in range(skip_chunks, 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
@ -307,10 +271,11 @@ class GraphBuilderService:
f"发送第 {batch_num}/{total_batches} 批数据 ({len(batch_chunks)} 块)...",
progress
)
# 构建episode数据
episodes = [
EpisodeData(data=chunk, type="text")
type('Episode', (), {'data': chunk, 'type': 'text'})()
for chunk in batch_chunks
]
@ -417,7 +382,7 @@ class GraphBuilderService:
entity_types=list(entity_types)
)
def get_graph_data(self, graph_id: str) -> Dict[str, Any]:
def get_graph_data(self, graph_id: str, ontology: Optional[Dict] = None) -> Dict[str, Any]:
"""
获取完整图谱数据包含详细信息
@ -442,10 +407,15 @@ class GraphBuilderService:
if created_at:
created_at = str(created_at)
entity_type = _classify_entity_type(node.name, node.summary or "", ontology)
labels = node.labels or []
if entity_type != "Entity" and entity_type not in labels:
labels = [entity_type] + [l for l in labels if l != "Entity"]
nodes_data.append({
"uuid": node.uuid_,
"name": node.name,
"labels": node.labels or [],
"labels": labels,
"summary": node.summary or "",
"attributes": node.attributes or {},
"created_at": created_at,

View File

@ -0,0 +1,491 @@
"""
Graphiti Adapter Drop-in replacement for the Zep Cloud client.
Exposes the same namespace as the Zep client so all consuming code
(graph_builder, zep_tools, zep_entity_reader, etc.) needs only a
one-line import swap:
from .graphiti_adapter import GraphitiAdapter
self.client = GraphitiAdapter()
Then all self.client.graph.* calls work unchanged.
"""
import asyncio
import threading
import uuid as _uuid_mod
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
from graphiti_core import Graphiti
from graphiti_core.nodes import EpisodeType, EntityNode
from graphiti_core.edges import EntityEdge
from graphiti_core.search.search_config import SearchConfig, SearchResults
from graphiti_core.search.search_config_recipes import (
NODE_HYBRID_SEARCH_RRF,
EDGE_HYBRID_SEARCH_RRF,
)
from graphiti_core.llm_client.config import LLMConfig
from graphiti_core.llm_client.gemini_client import GeminiClient
from graphiti_core.embedder.gemini import GeminiEmbedder, GeminiEmbedderConfig
from graphiti_core.cross_encoder.client import CrossEncoderClient
from ..config import Config
from ..utils.logger import get_logger
logger = get_logger('mirofish.graphiti_adapter')
class _GeminiReranker(CrossEncoderClient):
"""Simple reranker using Gemini — returns passages sorted by relevance."""
def __init__(self, client: GeminiClient):
self._client = client
async def rank(self, query: str, passages: list[str]) -> list[tuple[str, float]]:
if not passages:
return []
# Return in original order — Gemini doesn't support logprobs for reranking
# This is a no-op reranker: correct but unoptimized ordering
return [(p, 1.0 - i * 0.01) for i, p in enumerate(passages)]
# ---------------------------------------------------------------------------
# Persistent event loop in a dedicated background thread.
# All async calls are submitted here so the Neo4j driver (which is bound
# to one event loop) never crosses loop boundaries.
# ---------------------------------------------------------------------------
_loop: Optional[asyncio.AbstractEventLoop] = None
_loop_thread: Optional[threading.Thread] = None
_loop_lock = threading.Lock()
def _get_loop() -> asyncio.AbstractEventLoop:
global _loop, _loop_thread
if _loop is None:
with _loop_lock:
if _loop is None:
_loop = asyncio.new_event_loop()
_loop_thread = threading.Thread(
target=_loop.run_forever, daemon=True, name="graphiti-event-loop"
)
_loop_thread.start()
return _loop
def _run(coro):
"""Submit coroutine to the persistent event loop thread and wait for result."""
future = asyncio.run_coroutine_threadsafe(coro, _get_loop())
return future.result(timeout=300)
# ---------------------------------------------------------------------------
# Singleton Graphiti instance (one Neo4j driver for the whole process)
# ---------------------------------------------------------------------------
_graphiti_instance: Optional[Graphiti] = None
_graphiti_lock = threading.Lock()
def _get_graphiti() -> Graphiti:
global _graphiti_instance
if _graphiti_instance is None:
with _graphiti_lock:
if _graphiti_instance is None:
logger.info("Initializing Graphiti client...")
llm_cfg = LLMConfig(
api_key=Config.LLM_API_KEY,
model=Config.LLM_MODEL_NAME,
)
llm_client = GeminiClient(config=llm_cfg)
embedder = GeminiEmbedder(
config=GeminiEmbedderConfig(
api_key=Config.LLM_API_KEY,
embedding_model=Config.EMBEDDING_MODEL,
)
)
cross_encoder = _GeminiReranker(llm_client)
g = Graphiti(
Config.NEO4J_URI,
Config.NEO4J_USER,
Config.NEO4J_PASSWORD,
llm_client=llm_client,
embedder=embedder,
cross_encoder=cross_encoder,
)
# Use the persistent loop so the driver is bound to it from the start
_run(g.build_indices_and_constraints())
_graphiti_instance = g
logger.info("Graphiti client ready.")
return _graphiti_instance
# ---------------------------------------------------------------------------
# Compatibility data classes (mimic Zep response objects)
# ---------------------------------------------------------------------------
@dataclass
class _NodeResult:
"""Zep-compatible node object."""
uuid_: str
name: str
labels: List[str]
summary: str
attributes: Dict[str, Any]
created_at: Optional[str] = None
@property
def uuid(self):
return self.uuid_
@dataclass
class _EdgeResult:
"""Zep-compatible edge object."""
uuid_: str
name: str
fact: str
source_node_uuid: str
target_node_uuid: str
attributes: Dict[str, Any]
created_at: Optional[str] = None
valid_at: Optional[str] = None
invalid_at: Optional[str] = None
expired_at: Optional[str] = None
@property
def uuid(self):
return self.uuid_
@dataclass
class _EpisodeResult:
"""Zep-compatible episode object — always processed (Graphiti is sync)."""
uuid_: str
processed: bool = True
@property
def uuid(self):
return self.uuid_
@dataclass
class _SearchResults:
"""Zep-compatible search result object."""
edges: List[_EdgeResult] = field(default_factory=list)
nodes: List[_NodeResult] = field(default_factory=list)
# ---------------------------------------------------------------------------
# Helpers: convert Graphiti objects → Zep-compatible objects
# ---------------------------------------------------------------------------
def _to_ts(dt: Optional[datetime]) -> Optional[str]:
if dt is None:
return None
return dt.isoformat()
def _entity_node_to_result(n: EntityNode) -> _NodeResult:
return _NodeResult(
uuid_=n.uuid,
name=n.name,
labels=list(n.labels) if n.labels else ["Entity"],
summary=n.summary or "",
attributes=n.attributes or {},
created_at=_to_ts(n.created_at),
)
def _entity_edge_to_result(e: EntityEdge) -> _EdgeResult:
return _EdgeResult(
uuid_=e.uuid,
name=e.name or "",
fact=e.fact or "",
source_node_uuid=e.source_node_uuid,
target_node_uuid=e.target_node_uuid,
attributes={},
created_at=_to_ts(e.created_at),
valid_at=_to_ts(e.valid_at),
invalid_at=_to_ts(e.invalid_at),
expired_at=_to_ts(e.expired_at),
)
def _neo4j_record_to_node(record: Dict) -> _NodeResult:
labels = record.get("labels", ["Entity"])
if isinstance(labels, (list, tuple)):
labels = [str(l) for l in labels]
return _NodeResult(
uuid_=record.get("uuid", ""),
name=record.get("name", ""),
labels=labels,
summary=record.get("summary", ""),
attributes=record.get("attributes") or {},
created_at=str(record.get("created_at", "")) or None,
)
def _neo4j_record_to_edge(record: Dict) -> _EdgeResult:
def ts(v):
return str(v) if v else None
return _EdgeResult(
uuid_=record.get("uuid", ""),
name=record.get("name", ""),
fact=record.get("fact", ""),
source_node_uuid=record.get("source_node_uuid", ""),
target_node_uuid=record.get("target_node_uuid", ""),
attributes=record.get("attributes") or {},
created_at=ts(record.get("created_at")),
valid_at=ts(record.get("valid_at")),
invalid_at=ts(record.get("invalid_at")),
expired_at=ts(record.get("expired_at")),
)
# ---------------------------------------------------------------------------
# Neo4j direct query helpers
# ---------------------------------------------------------------------------
async def _neo4j_query(graphiti: Graphiti, cypher: str, params: Dict) -> List[Dict]:
"""Execute a read Cypher query and return list of record dicts."""
records, _, _ = await graphiti.driver.execute_query(cypher, params)
return [dict(r) for r in records]
async def _neo4j_write(graphiti: Graphiti, cypher: str, params: Dict) -> None:
"""Execute a write Cypher query."""
await graphiti.driver.execute_query(cypher, params)
# Cypher queries
_NODES_BY_GROUP = """
MATCH (n:Entity {group_id: $group_id})
RETURN n.uuid AS uuid, n.name AS name, n.summary AS summary,
labels(n) AS labels, n.created_at AS created_at,
n.attributes AS attributes
ORDER BY n.created_at ASC
SKIP $skip LIMIT $limit
"""
_EDGES_BY_GROUP = """
MATCH (s:Entity {group_id: $group_id})-[r:RELATES_TO]->(t:Entity {group_id: $group_id})
RETURN r.uuid AS uuid, r.name AS name, r.fact AS fact,
s.uuid AS source_node_uuid,
t.uuid AS target_node_uuid,
r.created_at AS created_at, r.valid_at AS valid_at,
r.invalid_at AS invalid_at, r.expired_at AS expired_at,
r.attributes AS attributes
ORDER BY r.created_at ASC
SKIP $skip LIMIT $limit
"""
_NODE_BY_UUID = """
MATCH (n:Entity {uuid: $uuid})
RETURN n.uuid AS uuid, n.name AS name, n.summary AS summary,
labels(n) AS labels, n.created_at AS created_at,
n.group_id AS group_id, n.attributes AS attributes
LIMIT 1
"""
_EDGES_BY_NODE_UUID = """
MATCH (s:Entity {uuid: $node_uuid})-[r:RELATES_TO]->(t:Entity)
RETURN r.uuid AS uuid, r.name AS name, r.fact AS fact,
s.uuid AS source_node_uuid,
t.uuid AS target_node_uuid,
r.created_at AS created_at, r.valid_at AS valid_at,
r.invalid_at AS invalid_at, r.expired_at AS expired_at
UNION
MATCH (s:Entity)-[r:RELATES_TO]->(t:Entity {uuid: $node_uuid})
RETURN r.uuid AS uuid, r.name AS name, r.fact AS fact,
s.uuid AS source_node_uuid,
t.uuid AS target_node_uuid,
r.created_at AS created_at, r.valid_at AS valid_at,
r.invalid_at AS invalid_at, r.expired_at AS expired_at
"""
_DELETE_GROUP = """
MATCH (n:Entity {group_id: $group_id})
DETACH DELETE n
"""
# ---------------------------------------------------------------------------
# Sub-namespaces
# ---------------------------------------------------------------------------
class _EpisodeNamespace:
def get(self, uuid_: str) -> _EpisodeResult:
"""Always returns processed=True — Graphiti is synchronous."""
return _EpisodeResult(uuid_=uuid_, processed=True)
class _NodeNamespace:
def __init__(self, graphiti: Graphiti):
self._g = graphiti
def get_by_graph_id(
self,
graph_id: str,
limit: int = 100,
uuid_cursor: Optional[str] = None,
) -> List[_NodeResult]:
"""Return nodes for a group. First call returns all; cursor call returns empty."""
if uuid_cursor is not None:
# Already fetched all on first call — signal end of pagination
return []
records = _run(_neo4j_query(
self._g, _NODES_BY_GROUP,
{"group_id": graph_id, "skip": 0, "limit": 10000}
))
return [_neo4j_record_to_node(r) for r in records]
def get(self, uuid_: str) -> _NodeResult:
records = _run(_neo4j_query(self._g, _NODE_BY_UUID, {"uuid": uuid_}))
if not records:
return _NodeResult(uuid_=uuid_, name="", labels=[], summary="", attributes={})
return _neo4j_record_to_node(records[0])
def get_entity_edges(self, node_uuid: str) -> List[_EdgeResult]:
records = _run(_neo4j_query(
self._g, _EDGES_BY_NODE_UUID, {"node_uuid": node_uuid}
))
return [_neo4j_record_to_edge(r) for r in records]
class _EdgeNamespace:
def __init__(self, graphiti: Graphiti):
self._g = graphiti
def get_by_graph_id(
self,
graph_id: str,
limit: int = 100,
uuid_cursor: Optional[str] = None,
) -> List[_EdgeResult]:
"""Return edges for a group. First call returns all; cursor call returns empty."""
if uuid_cursor is not None:
return []
records = _run(_neo4j_query(
self._g, _EDGES_BY_GROUP,
{"group_id": graph_id, "skip": 0, "limit": 50000}
))
return [_neo4j_record_to_edge(r) for r in records]
class _GraphNamespace:
def __init__(self, graphiti: Graphiti):
self._g = graphiti
self.node = _NodeNamespace(graphiti)
self.edge = _EdgeNamespace(graphiti)
self.episode = _EpisodeNamespace()
self._ontologies: Dict[str, Dict] = {} # graph_id -> ontology dict
def create(self, graph_id: str, name: str, description: str = "") -> None:
"""No-op — Graphiti uses group_id implicitly, no explicit creation needed."""
logger.info(f"Graph '{graph_id}' registered (group_id in Graphiti)")
def set_ontology(
self,
graph_ids: List[str],
entities: Any = None,
edges: Any = None,
) -> None:
"""Store ontology hints for use during episode ingestion. Graphiti extracts entities dynamically."""
for gid in graph_ids:
self._ontologies[gid] = {"entities": entities, "edges": edges}
logger.info(f"Ontology hints stored for graphs: {graph_ids}")
def add(self, graph_id: str, type: str = "text", data: str = "") -> _EpisodeResult:
"""Add a single text episode to the graph."""
result = _run(self._g.add_episode(
name=f"activity_{_uuid_mod.uuid4().hex[:8]}",
episode_body=data,
source_description="MiroFish simulation activity",
reference_time=datetime.now(timezone.utc),
source=EpisodeType.text,
group_id=graph_id,
update_communities=False,
))
ep_uuid_out = result.episode.uuid if result and result.episode else str(_uuid_mod.uuid4())
return _EpisodeResult(uuid_=ep_uuid_out)
def add_batch(self, graph_id: str, episodes: List[Any]) -> List[_EpisodeResult]:
"""Add a batch of episodes. Returns list of EpisodeResult with uuid_."""
results = []
for ep in episodes:
text = getattr(ep, 'data', '') or str(ep)
try:
result = _run(self._g.add_episode(
name=f"chunk_{_uuid_mod.uuid4().hex[:8]}",
episode_body=text,
source_description="MiroFish document chunk",
reference_time=datetime.now(timezone.utc),
source=EpisodeType.text,
group_id=graph_id,
update_communities=False,
))
ep_uuid_out = result.episode.uuid if result and result.episode else str(_uuid_mod.uuid4())
except Exception as e:
logger.warning(f"Episode add failed: {str(e)[:100]}, using placeholder uuid")
ep_uuid_out = str(_uuid_mod.uuid4())
results.append(_EpisodeResult(uuid_=ep_uuid_out))
return results
def search(
self,
graph_id: str,
query: str,
limit: int = 10,
scope: str = "edges",
reranker: Optional[str] = None,
) -> _SearchResults:
"""Semantic search over the graph. scope='edges'|'nodes'|'both'."""
try:
if scope == "nodes":
results = _run(self._g.search_(
query=query,
config=SearchConfig(
node_config=NODE_HYBRID_SEARCH_RRF.node_config,
limit=limit,
),
group_ids=[graph_id],
))
nodes = [_entity_node_to_result(n) for n in (results.nodes or [])]
return _SearchResults(nodes=nodes)
else:
edges = _run(self._g.search(
query=query,
group_ids=[graph_id],
num_results=limit,
))
return _SearchResults(edges=[_entity_edge_to_result(e) for e in (edges or [])])
except Exception as e:
logger.warning(f"Graph search failed: {str(e)[:150]}")
return _SearchResults()
def delete(self, graph_id: str) -> None:
"""Delete all nodes and edges for a group_id."""
_run(_neo4j_write(self._g, _DELETE_GROUP, {"group_id": graph_id}))
logger.info(f"Graph '{graph_id}' deleted from Neo4j")
# ---------------------------------------------------------------------------
# Main adapter class — drop-in for Zep(api_key=...)
# ---------------------------------------------------------------------------
class GraphitiAdapter:
"""
Drop-in replacement for `from zep_cloud.client import Zep`.
Usage:
self.client = GraphitiAdapter()
self.client.graph.create(graph_id, name)
self.client.graph.search(graph_id, query, limit, scope)
self.client.graph.node.get(uuid_)
...
"""
def __init__(self, api_key: Optional[str] = None):
# api_key ignored — kept for signature compatibility
graphiti = _get_graphiti()
self.graph = _GraphNamespace(graphiti)

View File

@ -16,7 +16,7 @@ from dataclasses import dataclass, field
from datetime import datetime
from openai import OpenAI
from zep_cloud.client import Zep
from .graphiti_adapter import GraphitiAdapter
from ..config import Config
from ..utils.logger import get_logger
@ -197,16 +197,8 @@ class OasisProfileGenerator:
base_url=self.base_url
)
# Zep客户端用于检索丰富上下文
self.zep_api_key = zep_api_key or Config.ZEP_API_KEY
self.zep_client = None
self.zep_client = GraphitiAdapter()
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:
logger.warning(f"Zep客户端初始化失败: {e}")
def generate_profile_from_entity(
self,

View File

@ -7,7 +7,7 @@ import time
from typing import Dict, Any, List, Optional, Set, Callable, TypeVar
from dataclasses import dataclass, field
from zep_cloud.client import Zep
from .graphiti_adapter import GraphitiAdapter
from ..config import Config
from ..utils.logger import get_logger
@ -79,11 +79,7 @@ class ZepEntityReader:
"""
def __init__(self, api_key: Optional[str] = None):
self.api_key = api_key or Config.ZEP_API_KEY
if not self.api_key:
raise ValueError("ZEP_API_KEY 未配置")
self.client = Zep(api_key=self.api_key)
self.client = GraphitiAdapter()
def _call_with_retry(
self,
@ -234,27 +230,51 @@ class ZepEntityReader:
FilteredEntities: 过滤后的实体集合
"""
logger.info(f"开始筛选图谱 {graph_id} 的实体...")
# Look up ontology from project to classify entities
ontology = None
try:
from ..models.project import ProjectManager
from .graph_builder import _classify_entity_type
for p in ProjectManager.list_projects():
if p.graph_id == graph_id and p.ontology:
ontology = p.ontology
break
except Exception:
pass
# 获取所有节点
all_nodes = self.get_all_nodes(graph_id)
total_count = len(all_nodes)
# Apply ontology-based classification so all nodes get proper type labels
if ontology:
for node in all_nodes:
labels = node.get("labels", [])
custom = [l for l in labels if l not in ("Entity", "Node")]
if not custom:
entity_type = _classify_entity_type(
node.get("name", ""), node.get("summary", ""), ontology
)
if entity_type != "Entity":
node["labels"] = [entity_type] + labels
# 获取所有边(用于后续关联查找)
all_edges = self.get_all_edges(graph_id) if enrich_with_edges else []
# 构建节点UUID到节点数据的映射
node_map = {n["uuid"]: n for n in all_nodes}
# 筛选符合条件的实体
filtered_entities = []
entity_types_found = set()
for node in all_nodes:
labels = node.get("labels", [])
# 筛选逻辑Labels必须包含除"Entity"和"Node"之外的标签
custom_labels = [l for l in labels if l not in ["Entity", "Node"]]
if not custom_labels:
# 只有默认标签,跳过
continue

View File

@ -12,7 +12,7 @@ from dataclasses import dataclass
from datetime import datetime
from queue import Queue, Empty
from zep_cloud.client import Zep
from .graphiti_adapter import GraphitiAdapter
from ..config import Config
from ..utils.logger import get_logger
@ -237,12 +237,7 @@ class ZepGraphMemoryUpdater:
api_key: Zep API Key可选默认从配置读取
"""
self.graph_id = graph_id
self.api_key = api_key or Config.ZEP_API_KEY
if not self.api_key:
raise ValueError("ZEP_API_KEY未配置")
self.client = Zep(api_key=self.api_key)
self.client = GraphitiAdapter()
# 活动队列
self._activity_queue: Queue = Queue()

View File

@ -13,7 +13,7 @@ import json
from typing import Dict, Any, List, Optional
from dataclasses import dataclass, field
from zep_cloud.client import Zep
from .graphiti_adapter import GraphitiAdapter
from ..config import Config
from ..utils.logger import get_logger
@ -422,11 +422,7 @@ 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 未配置")
self.client = Zep(api_key=self.api_key)
self.client = GraphitiAdapter()
# LLM客户端用于InsightForge生成子问题
self._llm_client = llm_client
logger.info("ZepToolsService 初始化完成")
@ -439,26 +435,38 @@ class ZepToolsService:
return self._llm_client
def _call_with_retry(self, func, operation_name: str, max_retries: int = None):
"""带重试机制的API调用"""
"""带重试机制的API调用自动处理429限速"""
max_retries = max_retries or self.MAX_RETRIES
last_exception = None
delay = self.RETRY_DELAY
for attempt in range(max_retries):
try:
return func()
except Exception as e:
last_exception = e
if attempt < max_retries - 1:
logger.warning(
f"Zep {operation_name}{attempt + 1} 次尝试失败: {str(e)[:100]}, "
f"{delay:.1f}秒后重试..."
)
time.sleep(delay)
# 检测429限速错误使用retry-after头部的等待时间
wait = delay
if hasattr(e, 'status_code') and e.status_code == 429:
retry_after = None
if hasattr(e, 'headers') and e.headers:
retry_after = e.headers.get('retry-after')
wait = float(retry_after) + 1 if retry_after else 65.0
logger.warning(
f"Zep {operation_name} 触发限速 (429), "
f"等待 {wait:.0f} 秒后重试 (第 {attempt + 1}/{max_retries - 1} 次)..."
)
else:
logger.warning(
f"Zep {operation_name}{attempt + 1} 次尝试失败: {str(e)[:100]}, "
f"{wait:.1f}秒后重试..."
)
time.sleep(wait)
delay *= 2
else:
logger.error(f"Zep {operation_name}{max_retries} 次尝试后仍失败: {str(e)}")
raise last_exception
def search_graph(

View File

@ -10,8 +10,7 @@ import time
from collections.abc import Callable
from typing import Any
from zep_cloud import InternalServerError
from zep_cloud.client import Zep
from typing import Any
from .logger import get_logger
@ -31,7 +30,7 @@ def _fetch_page_with_retry(
page_description: str = "page",
**kwargs: Any,
) -> list[Any]:
"""单页请求,失败时指数退避重试。仅重试网络/IO类瞬态错误"""
"""单页请求,失败时指数退避重试。自动处理429限速"""
if max_retries < 1:
raise ValueError("max_retries must be >= 1")
@ -41,13 +40,15 @@ def _fetch_page_with_retry(
for attempt in range(max_retries):
try:
return api_call(*args, **kwargs)
except (ConnectionError, TimeoutError, OSError, InternalServerError) as e:
except Exception as e:
last_exception = e
if attempt < max_retries - 1:
# 检测429限速使用retry-after头部指定的等待时间
wait = delay
logger.warning(
f"Zep {page_description} attempt {attempt + 1} failed: {str(e)[:100]}, retrying in {delay:.1f}s..."
f"Zep {page_description} attempt {attempt + 1} failed: {str(e)[:100]}, retrying in {wait:.1f}s..."
)
time.sleep(delay)
time.sleep(wait)
delay *= 2
else:
logger.error(f"Zep {page_description} failed after {max_retries} attempts: {str(e)}")
@ -57,7 +58,7 @@ def _fetch_page_with_retry(
def fetch_all_nodes(
client: Zep,
client: Any,
graph_id: str,
page_size: int = _DEFAULT_PAGE_SIZE,
max_items: int = _MAX_NODES,
@ -103,7 +104,7 @@ def fetch_all_nodes(
def fetch_all_edges(
client: Zep,
client: Any,
graph_id: str,
page_size: int = _DEFAULT_PAGE_SIZE,
max_retries: int = _DEFAULT_MAX_RETRIES,

View File

@ -12,26 +12,22 @@ dependencies = [
# 核心框架
"flask>=3.0.0",
"flask-cors>=6.0.0",
# LLM 相关
"openai>=1.0.0",
# Zep Cloud
"zep-cloud==3.13.0",
# OASIS 社交媒体模拟
"camel-oasis==0.2.5",
"camel-ai==0.2.78",
# 文件处理
"PyMuPDF>=1.24.0",
# 编码检测支持非UTF-8编码的文本文件
"charset-normalizer>=3.0.0",
"chardet>=5.0.0",
# 工具库
"python-dotenv>=1.0.0",
"pydantic>=2.0.0",
"graphiti-core>=0.3",
"google-genai>=1.68.0",
]
[project.optional-dependencies]

View File

@ -475,6 +475,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" },
]
[[package]]
name = "diskcache"
version = "5.6.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/3f/21/1c1ffc1a039ddcc459db43cc108658f32c57d271d7289a2794e401d0fdb6/diskcache-5.6.3.tar.gz", hash = "sha256:2c3a3fa2743d8535d832ec61c2054a1641f41775aa7c556758a109941e33e4fc", size = 67916, upload-time = "2023-08-31T06:12:00.316Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3f/27/4570e78fc0bf5ea0ca45eb1de3818a23787af9b390c0b0a0033a1b8236f9/diskcache-5.6.3-py3-none-any.whl", hash = "sha256:5e31b2d5fbad117cc363ebaf6b689474db18a1f6438bc82358b024abd4c2ca19", size = 45550, upload-time = "2023-08-31T06:11:58.822Z" },
]
[[package]]
name = "distlib"
version = "0.4.0"
@ -592,6 +601,63 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/51/c7/b64cae5dba3a1b138d7123ec36bb5ccd39d39939f18454407e5468f4763f/fsspec-2025.12.0-py3-none-any.whl", hash = "sha256:8bf1fe301b7d8acfa6e8571e3b1c3d158f909666642431cc78a1b7b4dbc5ec5b", size = 201422, upload-time = "2025-12-03T15:23:41.434Z" },
]
[[package]]
name = "google-auth"
version = "2.49.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
{ name = "pyasn1-modules" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ea/80/6a696a07d3d3b0a92488933532f03dbefa4a24ab80fb231395b9a2a1be77/google_auth-2.49.1.tar.gz", hash = "sha256:16d40da1c3c5a0533f57d268fe72e0ebb0ae1cc3b567024122651c045d879b64", size = 333825, upload-time = "2026-03-12T19:30:58.135Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/eb/c6c2478d8a8d633460be40e2a8a6f8f429171997a35a96f81d3b680dec83/google_auth-2.49.1-py3-none-any.whl", hash = "sha256:195ebe3dca18eddd1b3db5edc5189b76c13e96f29e73043b923ebcf3f1a860f7", size = 240737, upload-time = "2026-03-12T19:30:53.159Z" },
]
[package.optional-dependencies]
requests = [
{ name = "requests" },
]
[[package]]
name = "google-genai"
version = "1.68.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "distro" },
{ name = "google-auth", extra = ["requests"] },
{ name = "httpx" },
{ name = "pydantic" },
{ name = "requests" },
{ name = "sniffio" },
{ name = "tenacity" },
{ name = "typing-extensions" },
{ name = "websockets" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9c/2c/f059982dbcb658cc535c81bbcbe7e2c040d675f4b563b03cdb01018a4bc3/google_genai-1.68.0.tar.gz", hash = "sha256:ac30c0b8bc630f9372993a97e4a11dae0e36f2e10d7c55eacdca95a9fa14ca96", size = 511285, upload-time = "2026-03-18T01:03:18.243Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/84/de/7d3ee9c94b74c3578ea4f88d45e8de9405902f857932334d81e89bce3dfa/google_genai-1.68.0-py3-none-any.whl", hash = "sha256:a1bc9919c0e2ea2907d1e319b65471d3d6d58c54822039a249fe1323e4178d15", size = 750912, upload-time = "2026-03-18T01:03:15.983Z" },
]
[[package]]
name = "graphiti-core"
version = "0.11.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "diskcache" },
{ name = "neo4j" },
{ name = "numpy" },
{ name = "openai" },
{ name = "pydantic" },
{ name = "python-dotenv" },
{ name = "tenacity" },
]
sdist = { url = "https://files.pythonhosted.org/packages/30/94/3f84400e5f02ea8e9dc79784202de4173cbc16f4b3ad1bd4302da888e4d8/graphiti_core-0.11.6.tar.gz", hash = "sha256:31d26621834d7d4b8865059ab749feb18af15937b59c69598a640a5dfabea331", size = 71928, upload-time = "2025-05-15T17:58:02.304Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ac/2e/c8f22f01585bf173d1c82f6d4615511aebc75aeda764c69aa394446fa93c/graphiti_core-0.11.6-py3-none-any.whl", hash = "sha256:6ec4807a884f5ea88b942d0c8b7bcd2e107c7358ab4f98ef2a2092c229929707", size = 111001, upload-time = "2025-05-15T17:58:00.542Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
@ -1248,11 +1314,12 @@ dependencies = [
{ name = "charset-normalizer" },
{ name = "flask" },
{ name = "flask-cors" },
{ name = "google-genai" },
{ name = "graphiti-core" },
{ name = "openai" },
{ name = "pydantic" },
{ name = "pymupdf" },
{ name = "python-dotenv" },
{ name = "zep-cloud" },
]
[package.optional-dependencies]
@ -1276,6 +1343,8 @@ requires-dist = [
{ name = "charset-normalizer", specifier = ">=3.0.0" },
{ name = "flask", specifier = ">=3.0.0" },
{ name = "flask-cors", specifier = ">=6.0.0" },
{ name = "google-genai", specifier = ">=1.68.0" },
{ name = "graphiti-core", specifier = ">=0.3" },
{ name = "openai", specifier = ">=1.0.0" },
{ name = "pipreqs", marker = "extra == 'dev'", specifier = ">=0.5.0" },
{ name = "pydantic", specifier = ">=2.0.0" },
@ -1283,7 +1352,6 @@ requires-dist = [
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" },
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23.0" },
{ name = "python-dotenv", specifier = ">=1.0.0" },
{ name = "zep-cloud", specifier = "==3.13.0" },
]
provides-extras = ["dev"]
@ -1916,6 +1984,27 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842, upload-time = "2024-07-21T12:58:20.04Z" },
]
[[package]]
name = "pyasn1"
version = "0.6.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685, upload-time = "2026-03-17T01:06:53.382Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997, upload-time = "2026-03-17T01:06:52.036Z" },
]
[[package]]
name = "pyasn1-modules"
version = "0.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyasn1" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" },
]
[[package]]
name = "pycparser"
version = "2.23"
@ -2987,6 +3076,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/40/44/4a5f08c96eb108af5cb50b41f76142f0afa346dfa99d5296fe7202a11854/tabulate-0.9.0-py3-none-any.whl", hash = "sha256:024ca478df22e9340661486f85298cff5f6dcdba14f3813e8830015b9ed1948f", size = 35252, upload-time = "2022-10-06T17:21:44.262Z" },
]
[[package]]
name = "tenacity"
version = "9.1.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413, upload-time = "2026-02-07T10:45:33.841Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" },
]
[[package]]
name = "texttable"
version = "1.7.0"
@ -3488,19 +3586,3 @@ sdist = { url = "https://files.pythonhosted.org/packages/d4/c8/cc640404a0981e6c1
wheels = [
{ url = "https://files.pythonhosted.org/packages/8b/90/89a2ff242ccab6a24fbab18dbbabc67c51a6f0ed01f9a0f41689dc177419/yarg-0.1.9-py2.py3-none-any.whl", hash = "sha256:4f9cebdc00fac946c9bf2783d634e538a71c7d280a4d806d45fd4dc0ef441492", size = 19162, upload-time = "2014-08-11T22:01:41.104Z" },
]
[[package]]
name = "zep-cloud"
version = "3.13.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "httpx" },
{ name = "pydantic" },
{ name = "pydantic-core" },
{ name = "python-dateutil" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/32/c7/c835debf13302f8aaf8d0561ac6ff5a9bc15cc140cd692a1330fb1900c55/zep_cloud-3.13.0.tar.gz", hash = "sha256:c55d9c511773bb2177ae8e08546141404f87d2099affafabd7ec4b4505763e48", size = 63116, upload-time = "2025-11-20T15:25:40.745Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f7/e1/bbf03c6c8007c0cb238780e7fc6d8e1a52633893933a41aa09678618985a/zep_cloud-3.13.0-py3-none-any.whl", hash = "sha256:b2fbdeef73e262194c8f67b58f76471de6ee87e1a629541a09d8f7bbf475f12b", size = 110601, upload-time = "2025-11-20T15:25:38.484Z" },
]