This commit is contained in:
Heiko Garrelfs 2026-06-15 10:52:01 +00:00 committed by GitHub
commit b34ddf5f14
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 1249 additions and 300 deletions

View File

@ -5,10 +5,20 @@ LLM_API_KEY=your_api_key_here
LLM_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1 LLM_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
LLM_MODEL_NAME=qwen-plus LLM_MODEL_NAME=qwen-plus
# ===== ZEP记忆图谱配置 ===== # ===== Graph memory backend =====
# 每月免费额度即可支撑简单使用:https://app.getzep.com/ # Default cloud mode:
GRAPH_MEMORY_BACKEND=zep_cloud
# Zep Cloud configuration. Required only when GRAPH_MEMORY_BACKEND=zep_cloud.
# Free monthly quota is sufficient for simple usage: https://app.getzep.com/
ZEP_API_KEY=your_zep_api_key_here ZEP_API_KEY=your_zep_api_key_here
# Local on-premise mode. Enable with:
# GRAPH_MEMORY_BACKEND=graphiti_bridge
# GRAPHITI_BRIDGE_URL=http://graphiti-bridge:8008
# GRAPHITI_MODEL_NAME=gpt-5.4-mini
# GRAPHITI_EMBEDDING_MODEL_NAME=text-embedding-3-small
# ===== 加速 LLM 配置(可选)===== # ===== 加速 LLM 配置(可选)=====
# 注意如果不使用加速配置,env文件中就不要出现下面的配置项 # 注意如果不使用加速配置,env文件中就不要出现下面的配置项
LLM_BOOST_API_KEY=your_api_key_here LLM_BOOST_API_KEY=your_api_key_here

View File

@ -176,6 +176,10 @@ Reads `.env` from root directory by default, maps ports `3000 (frontend) / 5001
> Mirror address for faster pulling is provided as comments in `docker-compose.yml`, replace if needed. > Mirror address for faster pulling is provided as comments in `docker-compose.yml`, replace if needed.
### Optional: On-Premise Graph Memory
The default graph-memory backend remains Zep Cloud. For local graph memory, MiroFish can run a Graphiti bridge and FalkorDB through Docker Compose. See [On-Premise Graph Memory](./docs/on-prem-graph-memory.md) for architecture, configuration, health checks, and installation-agent instructions.
## 📬 Join the Conversation ## 📬 Join the Conversation
<div align="center"> <div align="center">

View File

@ -20,6 +20,46 @@ from ..utils.locale import t, get_locale, set_locale
logger = get_logger('mirofish.api.report') logger = get_logger('mirofish.api.report')
def _get_status_request_data():
"""Read status parameters from JSON bodies or query strings."""
if request.method == 'GET':
return request.args
return request.get_json(silent=True) or {}
def _find_report_task(task_manager: TaskManager, task_id: str = None, report_id: str = None, simulation_id: str = None):
if task_id:
task = task_manager.get_task(task_id)
return task.to_dict() if task else None
for task in task_manager.list_tasks('report_generate'):
metadata = task.get('metadata') or {}
if report_id and metadata.get('report_id') == report_id:
return task
if simulation_id and metadata.get('simulation_id') == simulation_id:
return task
return None
def _report_status_payload(report, progress=None):
status = report.status.value if hasattr(report.status, 'value') else str(report.status)
payload = {
"simulation_id": report.simulation_id,
"report_id": report.report_id,
"status": status,
"progress": 100 if report.status == ReportStatus.COMPLETED else 0,
"message": t('api.reportGenerated') if report.status == ReportStatus.COMPLETED else status,
"already_completed": report.status == ReportStatus.COMPLETED
}
if progress:
payload.update(progress)
payload["simulation_id"] = report.simulation_id
payload["report_id"] = report.report_id
payload["status"] = progress.get("status", status)
payload["already_completed"] = report.status == ReportStatus.COMPLETED
return payload
# ============== 报告生成接口 ============== # ============== 报告生成接口 ==============
@report_bp.route('/generate', methods=['POST']) @report_bp.route('/generate', methods=['POST'])
@ -200,58 +240,71 @@ def generate_report():
}), 500 }), 500
@report_bp.route('/generate/status', methods=['POST']) @report_bp.route('/generate/status', methods=['GET', 'POST'])
def get_generate_status(): def get_generate_status():
""" """
查询报告生成任务进度 查询报告生成任务进度
请求(JSON): 支持通过 JSON body 或 query string 传入 task_id、report_id 或 simulation_id。
{
"task_id": "task_xxxx", // 可选,generate返回的task_id
"simulation_id": "sim_xxxx" // 可选,模拟ID
}
返回:
{
"success": true,
"data": {
"task_id": "task_xxxx",
"status": "processing|completed|failed",
"progress": 45,
"message": "..."
}
}
""" """
try: try:
data = request.get_json() or {} data = _get_status_request_data()
task_id = data.get('task_id') task_id = data.get('task_id')
report_id = data.get('report_id')
simulation_id = data.get('simulation_id') simulation_id = data.get('simulation_id')
# 如果提供了simulation_id,先检查是否已有完成的报告 task_manager = TaskManager()
if simulation_id:
existing_report = ReportManager.get_report_by_simulation(simulation_id) if report_id:
if existing_report and existing_report.status == ReportStatus.COMPLETED: report = ReportManager.get_report(report_id)
if report:
return jsonify({ return jsonify({
"success": True, "success": True,
"data": { "data": _report_status_payload(report, ReportManager.get_progress(report_id))
"simulation_id": simulation_id,
"report_id": existing_report.report_id,
"status": "completed",
"progress": 100,
"message": t('api.reportGenerated'),
"already_completed": True
}
}) })
task = _find_report_task(task_manager, report_id=report_id)
if task:
return jsonify({"success": True, "data": task})
return jsonify({
"success": False,
"error": t('api.reportNotFound', id=report_id)
}), 404
if simulation_id:
existing_report = ReportManager.get_report_by_simulation(simulation_id)
if existing_report:
return jsonify({
"success": True,
"data": _report_status_payload(
existing_report,
ReportManager.get_progress(existing_report.report_id)
)
})
task = _find_report_task(task_manager, simulation_id=simulation_id)
if task:
return jsonify({"success": True, "data": task})
return jsonify({
"success": True,
"data": {
"simulation_id": simulation_id,
"status": "not_started",
"progress": 0,
"message": t('api.requireTaskOrSimId')
}
})
if not task_id: if not task_id:
return jsonify({ return jsonify({
"success": False, "success": False,
"error": t('api.requireTaskOrSimId') "error": t('api.requireTaskOrSimId')
}), 400 }), 400
task_manager = TaskManager() task = _find_report_task(task_manager, task_id=task_id)
task = task_manager.get_task(task_id)
if not task: if not task:
return jsonify({ return jsonify({
@ -261,7 +314,7 @@ def get_generate_status():
return jsonify({ return jsonify({
"success": True, "success": True,
"data": task.to_dict() "data": task
}) })
except Exception as e: except Exception as e:

View File

@ -32,6 +32,15 @@ class Config:
LLM_BASE_URL = os.environ.get('LLM_BASE_URL', 'https://api.openai.com/v1') 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') LLM_MODEL_NAME = os.environ.get('LLM_MODEL_NAME', 'gpt-4o-mini')
# Graph memory backend configuration
GRAPH_MEMORY_BACKEND = os.environ.get('GRAPH_MEMORY_BACKEND', 'zep_cloud')
GRAPHITI_MODEL_NAME = os.environ.get('GRAPHITI_MODEL_NAME', LLM_MODEL_NAME)
GRAPHITI_EMBEDDING_MODEL_NAME = os.environ.get('GRAPHITI_EMBEDDING_MODEL_NAME', 'text-embedding-3-small')
GRAPHITI_BRIDGE_URL = os.environ.get('GRAPHITI_BRIDGE_URL', 'http://graphiti-bridge:8008')
FALKORDB_HOST = os.environ.get('FALKORDB_HOST', 'localhost')
FALKORDB_PORT = int(os.environ.get('FALKORDB_PORT', '6379'))
FALKORDB_DATABASE = os.environ.get('FALKORDB_DATABASE', 'mirofish')
# Zep配置 # Zep配置
ZEP_API_KEY = os.environ.get('ZEP_API_KEY') ZEP_API_KEY = os.environ.get('ZEP_API_KEY')
@ -69,7 +78,8 @@ class Config:
errors: list[str] = [] errors: list[str] = []
if not cls.LLM_API_KEY: if not cls.LLM_API_KEY:
errors.append("LLM_API_KEY 未配置") errors.append("LLM_API_KEY 未配置")
if not cls.ZEP_API_KEY: graph_backend = (cls.GRAPH_MEMORY_BACKEND or 'zep_cloud').lower()
if graph_backend in {'zep', 'zep_cloud', 'zep-cloud'} and not cls.ZEP_API_KEY:
errors.append("ZEP_API_KEY 未配置") errors.append("ZEP_API_KEY 未配置")
return errors return errors

View File

@ -0,0 +1,13 @@
"""Graph memory backend adapters."""
from .base import GraphMemoryAdapter
from .factory import create_graph_memory_adapter
from .graphiti_bridge_adapter import GraphitiBridgeGraphMemoryAdapter
from .zep_cloud_adapter import ZepCloudGraphMemoryAdapter
__all__ = [
"GraphMemoryAdapter",
"GraphitiBridgeGraphMemoryAdapter",
"ZepCloudGraphMemoryAdapter",
"create_graph_memory_adapter",
]

View File

@ -0,0 +1,66 @@
"""Graph memory adapter contracts.
This module defines the narrow graph-memory surface Mirofish needs. Concrete
backends can implement it without leaking vendor SDK details into services.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Any, Protocol
class GraphMemoryAdapter(ABC):
"""Backend-neutral graph memory interface used by Mirofish services."""
@abstractmethod
def create_graph(self, graph_id: str, name: str, description: str) -> Any:
"""Create a graph and return the backend response."""
@abstractmethod
def set_ontology(self, graph_id: str, ontology: dict[str, Any]) -> Any:
"""Apply ontology definitions for a graph."""
@abstractmethod
def add_text_batch(self, graph_id: str, chunks: list[str]) -> Any:
"""Add a batch of text episodes to a graph."""
@abstractmethod
def add_text(self, graph_id: str, text: str) -> Any:
"""Add a single text episode to a graph."""
@abstractmethod
def get_episode(self, episode_uuid: str) -> Any:
"""Return one episode by UUID."""
@abstractmethod
def get_all_nodes(self, graph_id: str) -> list[Any]:
"""Return all nodes for a graph."""
@abstractmethod
def get_all_edges(self, graph_id: str) -> list[Any]:
"""Return all edges for a graph."""
@abstractmethod
def search(self, graph_id: str, query: str, limit: int = 10, scope: str = "edges", **kwargs: Any) -> Any:
"""Search graph memory."""
@abstractmethod
def get_node(self, node_uuid: str) -> Any:
"""Return one node by UUID."""
@abstractmethod
def get_node_edges(self, node_uuid: str) -> list[Any]:
"""Return edges related to one node."""
@abstractmethod
def delete_graph(self, graph_id: str) -> Any:
"""Delete a graph."""
class SupportsRawClient(Protocol):
"""Compatibility escape hatch for legacy code not yet adapter-native."""
@property
def raw_client(self) -> Any:
"""Return the underlying SDK client."""

View File

@ -0,0 +1,23 @@
"""Graph memory adapter factory."""
from __future__ import annotations
from typing import Optional
from ..config import Config
from .base import GraphMemoryAdapter
from .zep_cloud_adapter import ZepCloudGraphMemoryAdapter
def create_graph_memory_adapter(api_key: Optional[str] = None, backend: Optional[str] = None) -> GraphMemoryAdapter:
selected_backend = (backend or Config.GRAPH_MEMORY_BACKEND).strip().lower()
if selected_backend in {"zep", "zep_cloud", "zep-cloud"}:
return ZepCloudGraphMemoryAdapter(api_key=api_key or Config.ZEP_API_KEY)
if selected_backend in {"graphiti", "graphiti_core", "graphiti-core", "graphiti_bridge", "graphiti-bridge"}:
from .graphiti_bridge_adapter import GraphitiBridgeGraphMemoryAdapter
return GraphitiBridgeGraphMemoryAdapter(api_key=api_key or Config.LLM_API_KEY)
raise ValueError(f"Unsupported GRAPH_MEMORY_BACKEND: {selected_backend}")

View File

@ -0,0 +1,128 @@
"""HTTP adapter for the on-premise Graphiti bridge service."""
from __future__ import annotations
import json
from types import SimpleNamespace
from typing import Any
from urllib.error import HTTPError
from urllib.parse import quote, urlencode
from urllib.request import Request, urlopen
from .base import GraphMemoryAdapter
from ..config import Config
class GraphitiBridgeGraphMemoryAdapter(GraphMemoryAdapter):
"""Graph memory adapter backed by the local Graphiti bridge service."""
def __init__(self, api_key: str | None = None, base_url: str | None = None):
self.base_url = (base_url or Config.GRAPHITI_BRIDGE_URL).rstrip("/")
self._node_graph_index: dict[str, str] = {}
@property
def raw_client(self) -> None:
return None
def create_graph(self, graph_id: str, name: str, description: str) -> Any:
return self._to_namespace(self._request("POST", "/graphs", {"graph_id": graph_id, "name": name, "description": description}))
def set_ontology(self, graph_id: str, ontology: dict[str, Any]) -> Any:
return self._request("POST", f"/graphs/{quote(graph_id)}/ontology", ontology)
def add_text_batch(self, graph_id: str, chunks: list[str]) -> list[Any]:
data = self._request("POST", f"/graphs/{quote(graph_id)}/episodes", {"chunks": chunks})
return [self._episode(item) for item in data.get("episodes", [])]
def add_text(self, graph_id: str, text: str) -> Any:
data = self._request("POST", f"/graphs/{quote(graph_id)}/episodes", {"text": text})
episodes = data.get("episodes", [])
return self._episode(episodes[0]) if episodes else self._episode({"uuid": None, "processed": True})
def get_episode(self, episode_uuid: str) -> Any:
return self._episode({"uuid": episode_uuid, "processed": True})
def get_all_nodes(self, graph_id: str) -> list[Any]:
data = self._request("GET", f"/graphs/{quote(graph_id)}/nodes")
nodes = [self._node(item) for item in data.get("nodes", [])]
for node in nodes:
self._node_graph_index[node.uuid_] = graph_id
return nodes
def get_all_edges(self, graph_id: str) -> list[Any]:
data = self._request("GET", f"/graphs/{quote(graph_id)}/edges")
return [self._edge(item) for item in data.get("edges", [])]
def search(self, graph_id: str, query: str, limit: int = 10, scope: str = "edges", **kwargs: Any) -> Any:
data = self._request("POST", f"/graphs/{quote(graph_id)}/search", {"query": query, "limit": limit, "scope": scope})
nodes = [self._node(item) for item in data.get("nodes", [])]
for node in nodes:
self._node_graph_index[node.uuid_] = graph_id
return SimpleNamespace(edges=[self._edge(item) for item in data.get("edges", [])], nodes=nodes)
def get_node(self, node_uuid: str) -> Any:
graph_id = self._node_graph_index.get(node_uuid)
if not graph_id:
return None
query = urlencode({"graph_id": graph_id})
data = self._request("GET", f"/nodes/{quote(node_uuid)}?{query}")
node = data.get("node")
return self._node(node) if node else None
def get_node_edges(self, node_uuid: str) -> list[Any]:
graph_id = self._node_graph_index.get(node_uuid)
if not graph_id:
return []
query = urlencode({"graph_id": graph_id})
data = self._request("GET", f"/nodes/{quote(node_uuid)}/edges?{query}")
return [self._edge(item) for item in data.get("edges", [])]
def delete_graph(self, graph_id: str) -> Any:
return self._request("DELETE", f"/graphs/{quote(graph_id)}")
def _request(self, method: str, path: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
body = None if payload is None else json.dumps(payload).encode("utf-8")
headers = {"Content-Type": "application/json"}
req = Request(f"{self.base_url}{path}", data=body, headers=headers, method=method)
try:
with urlopen(req, timeout=120) as response:
raw = response.read().decode("utf-8")
return json.loads(raw) if raw else {}
except HTTPError as exc:
error_body = exc.read().decode("utf-8", errors="replace")
raise RuntimeError(f"Graphiti bridge request failed: {exc.code} {error_body}") from exc
def _episode(self, data: dict[str, Any]) -> Any:
uuid = data.get("uuid") or data.get("uuid_")
return SimpleNamespace(uuid_=uuid, uuid=uuid, processed=data.get("processed", True))
def _node(self, data: dict[str, Any]) -> Any:
uuid = data.get("uuid") or data.get("uuid_") or ""
return SimpleNamespace(
uuid_=uuid,
uuid=uuid,
name=data.get("name") or "",
labels=data.get("labels") or [],
summary=data.get("summary") or "",
attributes=data.get("attributes") or {},
created_at=data.get("created_at"),
)
def _edge(self, data: dict[str, Any]) -> Any:
uuid = data.get("uuid") or data.get("uuid_") or ""
return SimpleNamespace(
uuid_=uuid,
uuid=uuid,
name=data.get("name") or "",
fact=data.get("fact") or "",
source_node_uuid=data.get("source_node_uuid") or "",
target_node_uuid=data.get("target_node_uuid") or "",
attributes=data.get("attributes") or {},
created_at=data.get("created_at"),
valid_at=data.get("valid_at"),
invalid_at=data.get("invalid_at"),
expired_at=data.get("expired_at"),
)
def _to_namespace(self, data: dict[str, Any]) -> Any:
return SimpleNamespace(**data)

View File

@ -0,0 +1,121 @@
"""Zep Cloud graph memory adapter."""
from __future__ import annotations
import warnings
from typing import Any, Optional
from pydantic import Field
from zep_cloud import EpisodeData, EntityEdgeSourceTarget
from zep_cloud.client import Zep
from zep_cloud.external_clients.ontology import EntityModel, EntityText, EdgeModel
from .base import GraphMemoryAdapter
from ..utils.zep_paging import fetch_all_edges, fetch_all_nodes
class ZepCloudGraphMemoryAdapter(GraphMemoryAdapter):
"""Adapter preserving the existing Zep Cloud behavior."""
RESERVED_NAMES = {"uuid", "name", "group_id", "name_embedding", "summary", "created_at"}
def __init__(self, api_key: str):
if not api_key:
raise ValueError("ZEP_API_KEY 未配置")
self._client = Zep(api_key=api_key)
@property
def raw_client(self) -> Zep:
return self._client
def create_graph(self, graph_id: str, name: str, description: str) -> Any:
return self._client.graph.create(graph_id=graph_id, name=name, description=description)
def set_ontology(self, graph_id: str, ontology: dict[str, Any]) -> Any:
warnings.filterwarnings("ignore", category=UserWarning, module="pydantic")
entity_types: dict[str, type[EntityModel]] = {}
for entity_def in ontology.get("entity_types", []):
name = entity_def["name"]
description = entity_def.get("description", f"A {name} entity.")
attrs: dict[str, Any] = {"__doc__": description}
annotations: dict[str, Any] = {}
for attr_def in entity_def.get("attributes", []):
attr_name = self._safe_attr_name(attr_def["name"])
attr_desc = attr_def.get("description", attr_name)
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: dict[str, tuple[type[EdgeModel], list[EntityEdgeSourceTarget]]] = {}
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 = self._safe_attr_name(attr_def["name"])
attr_desc = attr_def.get("description", attr_name)
attrs[attr_name] = Field(description=attr_desc, default=None)
annotations[attr_name] = Optional[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 = [
EntityEdgeSourceTarget(source=st.get("source", "Entity"), target=st.get("target", "Entity"))
for st in edge_def.get("source_targets", [])
]
if source_targets:
edge_definitions[name] = (edge_class, source_targets)
if not entity_types and not edge_definitions:
return None
return 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,
)
def add_text_batch(self, graph_id: str, chunks: list[str]) -> Any:
episodes = [EpisodeData(data=chunk, type="text") for chunk in chunks]
return self._client.graph.add_batch(graph_id=graph_id, episodes=episodes)
def add_text(self, graph_id: str, text: str) -> Any:
return self._client.graph.add(graph_id=graph_id, type="text", data=text)
def get_episode(self, episode_uuid: str) -> Any:
return self._client.graph.episode.get(uuid_=episode_uuid)
def get_all_nodes(self, graph_id: str) -> list[Any]:
return fetch_all_nodes(self._client, graph_id)
def get_all_edges(self, graph_id: str) -> list[Any]:
return fetch_all_edges(self._client, graph_id)
def search(self, graph_id: str, query: str, limit: int = 10, scope: str = "edges", **kwargs: Any) -> Any:
return self._client.graph.search(graph_id=graph_id, query=query, limit=limit, scope=scope, **kwargs)
def get_node(self, node_uuid: str) -> Any:
return self._client.graph.node.get(uuid_=node_uuid)
def get_node_edges(self, node_uuid: str) -> list[Any]:
return self._client.graph.node.get_entity_edges(node_uuid=node_uuid)
def delete_graph(self, graph_id: str) -> Any:
return self._client.graph.delete(graph_id=graph_id)
@classmethod
def _safe_attr_name(cls, attr_name: str) -> str:
if attr_name.lower() in cls.RESERVED_NAMES:
return f"entity_{attr_name}"
return attr_name

View File

@ -10,12 +10,9 @@ import threading
from typing import Dict, Any, List, Optional, Callable from typing import Dict, Any, List, Optional, Callable
from dataclasses import dataclass from dataclasses import dataclass
from zep_cloud.client import Zep
from zep_cloud import EpisodeData, EntityEdgeSourceTarget
from ..config import Config from ..config import Config
from ..models.task import TaskManager, TaskStatus from ..models.task import TaskManager, TaskStatus
from ..utils.zep_paging import fetch_all_nodes, fetch_all_edges from ..graph_memory import create_graph_memory_adapter
from .text_processor import TextProcessor from .text_processor import TextProcessor
from ..utils.locale import t, get_locale, set_locale from ..utils.locale import t, get_locale, set_locale
@ -48,7 +45,8 @@ class GraphBuilderService:
if not self.api_key: if not self.api_key:
raise ValueError("ZEP_API_KEY 未配置") raise ValueError("ZEP_API_KEY 未配置")
self.client = Zep(api_key=self.api_key) self.graph_memory = create_graph_memory_adapter(api_key=self.api_key)
self.client = getattr(self.graph_memory, 'raw_client', None)
self.task_manager = TaskManager() self.task_manager = TaskManager()
def build_graph_async( def build_graph_async(
@ -194,7 +192,7 @@ class GraphBuilderService:
"""创建Zep图谱(公开方法)""" """创建Zep图谱(公开方法)"""
graph_id = f"mirofish_{uuid.uuid4().hex[:16]}" graph_id = f"mirofish_{uuid.uuid4().hex[:16]}"
self.client.graph.create( self.graph_memory.create_graph(
graph_id=graph_id, graph_id=graph_id,
name=name, name=name,
description="MiroFish Social Simulation Graph" description="MiroFish Social Simulation Graph"
@ -204,92 +202,7 @@ class GraphBuilderService:
def set_ontology(self, graph_id: str, ontology: Dict[str, Any]): def set_ontology(self, graph_id: str, ontology: Dict[str, Any]):
"""设置图谱本体(公开方法)""" """设置图谱本体(公开方法)"""
import warnings self.graph_memory.set_ontology(graph_id, ontology)
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,
)
def add_text_batches( def add_text_batches(
self, self,
@ -314,17 +227,11 @@ class GraphBuilderService:
progress progress
) )
# 构建episode数据 # 发送到图谱记忆后端
episodes = [
EpisodeData(data=chunk, type="text")
for chunk in batch_chunks
]
# 发送到Zep
try: try:
batch_result = self.client.graph.add_batch( batch_result = self.graph_memory.add_text_batch(
graph_id=graph_id, graph_id=graph_id,
episodes=episodes chunks=batch_chunks
) )
# 收集返回的 episode uuid # 收集返回的 episode uuid
@ -376,7 +283,7 @@ class GraphBuilderService:
# 检查每个 episode 的处理状态 # 检查每个 episode 的处理状态
for ep_uuid in list(pending_episodes): for ep_uuid in list(pending_episodes):
try: try:
episode = self.client.graph.episode.get(uuid_=ep_uuid) episode = self.graph_memory.get_episode(ep_uuid)
is_processed = getattr(episode, 'processed', False) is_processed = getattr(episode, 'processed', False)
if is_processed: if is_processed:
@ -403,10 +310,10 @@ class GraphBuilderService:
def _get_graph_info(self, graph_id: str) -> GraphInfo: def _get_graph_info(self, graph_id: str) -> GraphInfo:
"""获取图谱信息""" """获取图谱信息"""
# 获取节点(分页) # 获取节点(分页)
nodes = fetch_all_nodes(self.client, graph_id) nodes = self.graph_memory.get_all_nodes(graph_id)
# 获取边(分页) # 获取边(分页)
edges = fetch_all_edges(self.client, graph_id) edges = self.graph_memory.get_all_edges(graph_id)
# 统计实体类型 # 统计实体类型
entity_types = set() entity_types = set()
@ -433,8 +340,8 @@ class GraphBuilderService:
Returns: Returns:
包含nodes和edges的字典,包括时间信息、属性等详细数据 包含nodes和edges的字典,包括时间信息、属性等详细数据
""" """
nodes = fetch_all_nodes(self.client, graph_id) nodes = self.graph_memory.get_all_nodes(graph_id)
edges = fetch_all_edges(self.client, graph_id) edges = self.graph_memory.get_all_edges(graph_id)
# 创建节点映射用于获取节点名称 # 创建节点映射用于获取节点名称
node_map = {} node_map = {}
@ -502,5 +409,5 @@ class GraphBuilderService:
def delete_graph(self, graph_id: str): def delete_graph(self, graph_id: str):
"""删除图谱""" """删除图谱"""
self.client.graph.delete(graph_id=graph_id) self.graph_memory.delete_graph(graph_id)

View File

@ -16,12 +16,11 @@ from dataclasses import dataclass, field
from datetime import datetime from datetime import datetime
from openai import OpenAI from openai import OpenAI
from zep_cloud.client import Zep
from ..config import Config from ..config import Config
from ..utils.logger import get_logger from ..utils.logger import get_logger
from ..utils.locale import get_language_instruction, get_locale, set_locale, t from ..utils.locale import get_language_instruction, get_locale, set_locale, t
from .zep_entity_reader import EntityNode, ZepEntityReader from .zep_entity_reader import EntityNode, ZepEntityReader
from ..graph_memory import create_graph_memory_adapter
logger = get_logger('mirofish.oasis_profile') logger = get_logger('mirofish.oasis_profile')
@ -205,7 +204,8 @@ class OasisProfileGenerator:
if self.zep_api_key: if self.zep_api_key:
try: try:
self.zep_client = Zep(api_key=self.zep_api_key) self.graph_memory = create_graph_memory_adapter(api_key=self.zep_api_key)
self.zep_client = getattr(self.graph_memory, 'raw_client', None)
except Exception as e: except Exception as e:
logger.warning(f"Zep客户端初始化失败: {e}") logger.warning(f"Zep客户端初始化失败: {e}")
@ -324,7 +324,7 @@ class OasisProfileGenerator:
for attempt in range(max_retries): for attempt in range(max_retries):
try: try:
return self.zep_client.graph.search( return self.graph_memory.search(
query=comprehensive_query, query=comprehensive_query,
graph_id=self.graph_id, graph_id=self.graph_id,
limit=30, limit=30,
@ -349,7 +349,7 @@ class OasisProfileGenerator:
for attempt in range(max_retries): for attempt in range(max_retries):
try: try:
return self.zep_client.graph.search( return self.graph_memory.search(
query=comprehensive_query, query=comprehensive_query,
graph_id=self.graph_id, graph_id=self.graph_id,
limit=20, limit=20,

View File

@ -1702,7 +1702,7 @@ class SimulationRunner:
"agent_id": user_id, "agent_id": user_id,
"response": info.get("response", info), "response": info.get("response", info),
"prompt": info.get("prompt", ""), "prompt": info.get("prompt", ""),
"timestamp": created_at, "timestamp": str(created_at) if created_at is not None else "",
"platform": platform_name "platform": platform_name
}) })
@ -1713,6 +1713,28 @@ class SimulationRunner:
return results return results
@staticmethod
def _timestamp_sort_value(value: Any) -> float:
"""Return a stable numeric sort value for mixed SQLite timestamp formats."""
if value is None:
return 0.0
if isinstance(value, (int, float)):
return float(value)
text_value = str(value).strip()
if not text_value:
return 0.0
try:
return float(text_value)
except ValueError:
pass
try:
return datetime.fromisoformat(text_value.replace("Z", "+00:00")).timestamp()
except ValueError:
return 0.0
@classmethod @classmethod
def get_interview_history( def get_interview_history(
cls, cls,
@ -1757,12 +1779,11 @@ class SimulationRunner:
) )
results.extend(platform_results) results.extend(platform_results)
# 按时间降序排序 # 按时间降序排序,兼容不同平台写入的字符串/数字时间戳
results.sort(key=lambda x: x.get("timestamp", ""), reverse=True) results.sort(key=lambda x: cls._timestamp_sort_value(x.get("timestamp")), reverse=True)
# 如果查询了多个平台,限制总数 # 如果查询了多个平台,限制总数
if len(platforms) > 1 and len(results) > limit: if len(platforms) > 1 and len(results) > limit:
results = results[:limit] results = results[:limit]
return results return results

View File

@ -7,11 +7,9 @@ import time
from typing import Dict, Any, List, Optional, Set, Callable, TypeVar from typing import Dict, Any, List, Optional, Set, Callable, TypeVar
from dataclasses import dataclass, field from dataclasses import dataclass, field
from zep_cloud.client import Zep
from ..config import Config from ..config import Config
from ..utils.logger import get_logger from ..utils.logger import get_logger
from ..utils.zep_paging import fetch_all_nodes, fetch_all_edges from ..graph_memory import create_graph_memory_adapter
logger = get_logger('mirofish.zep_entity_reader') logger = get_logger('mirofish.zep_entity_reader')
@ -83,7 +81,8 @@ class ZepEntityReader:
if not self.api_key: if not self.api_key:
raise ValueError("ZEP_API_KEY 未配置") raise ValueError("ZEP_API_KEY 未配置")
self.client = Zep(api_key=self.api_key) self.graph_memory = create_graph_memory_adapter(api_key=self.api_key)
self.client = getattr(self.graph_memory, 'raw_client', None)
def _call_with_retry( def _call_with_retry(
self, self,
@ -136,7 +135,7 @@ class ZepEntityReader:
""" """
logger.info(f"获取图谱 {graph_id} 的所有节点...") logger.info(f"获取图谱 {graph_id} 的所有节点...")
nodes = fetch_all_nodes(self.client, graph_id) nodes = self.graph_memory.get_all_nodes(graph_id)
nodes_data = [] nodes_data = []
for node in nodes: for node in nodes:
@ -163,7 +162,7 @@ class ZepEntityReader:
""" """
logger.info(f"获取图谱 {graph_id} 的所有边...") logger.info(f"获取图谱 {graph_id} 的所有边...")
edges = fetch_all_edges(self.client, graph_id) edges = self.graph_memory.get_all_edges(graph_id)
edges_data = [] edges_data = []
for edge in edges: for edge in edges:
@ -192,7 +191,7 @@ class ZepEntityReader:
try: try:
# 使用重试机制调用Zep API # 使用重试机制调用Zep API
edges = self._call_with_retry( edges = self._call_with_retry(
func=lambda: self.client.graph.node.get_entity_edges(node_uuid=node_uuid), func=lambda: self.graph_memory.get_node_edges(node_uuid),
operation_name=f"获取节点边(node={node_uuid[:8]}...)" operation_name=f"获取节点边(node={node_uuid[:8]}...)"
) )
@ -348,7 +347,7 @@ class ZepEntityReader:
try: try:
# 使用重试机制获取节点 # 使用重试机制获取节点
node = self._call_with_retry( node = self._call_with_retry(
func=lambda: self.client.graph.node.get(uuid_=entity_uuid), func=lambda: self.graph_memory.get_node(entity_uuid),
operation_name=f"获取节点详情(uuid={entity_uuid[:8]}...)" operation_name=f"获取节点详情(uuid={entity_uuid[:8]}...)"
) )

View File

@ -12,11 +12,10 @@ from dataclasses import dataclass
from datetime import datetime from datetime import datetime
from queue import Queue, Empty from queue import Queue, Empty
from zep_cloud.client import Zep
from ..config import Config from ..config import Config
from ..utils.logger import get_logger from ..utils.logger import get_logger
from ..utils.locale import get_locale, set_locale from ..utils.locale import get_locale, set_locale
from ..graph_memory import create_graph_memory_adapter
logger = get_logger('mirofish.zep_graph_memory_updater') logger = get_logger('mirofish.zep_graph_memory_updater')
@ -243,7 +242,8 @@ class ZepGraphMemoryUpdater:
if not self.api_key: if not self.api_key:
raise ValueError("ZEP_API_KEY未配置") raise ValueError("ZEP_API_KEY未配置")
self.client = Zep(api_key=self.api_key) self.graph_memory = create_graph_memory_adapter(api_key=self.api_key)
self.client = getattr(self.graph_memory, 'raw_client', None)
# 活动队列 # 活动队列
self._activity_queue: Queue = Queue() self._activity_queue: Queue = Queue()
@ -411,10 +411,9 @@ class ZepGraphMemoryUpdater:
# 带重试的发送 # 带重试的发送
for attempt in range(self.MAX_RETRIES): for attempt in range(self.MAX_RETRIES):
try: try:
self.client.graph.add( self.graph_memory.add_text(
graph_id=self.graph_id, graph_id=self.graph_id,
type="text", text=combined_text
data=combined_text
) )
self._total_sent += 1 self._total_sent += 1

View File

@ -13,13 +13,11 @@ import json
from typing import Dict, Any, List, Optional from typing import Dict, Any, List, Optional
from dataclasses import dataclass, field from dataclasses import dataclass, field
from zep_cloud.client import Zep
from ..config import Config from ..config import Config
from ..utils.logger import get_logger from ..utils.logger import get_logger
from ..utils.llm_client import LLMClient from ..utils.llm_client import LLMClient
from ..utils.locale import get_locale, t from ..utils.locale import get_locale, t
from ..utils.zep_paging import fetch_all_nodes, fetch_all_edges from ..graph_memory import create_graph_memory_adapter
logger = get_logger('mirofish.zep_tools') logger = get_logger('mirofish.zep_tools')
@ -427,7 +425,8 @@ class ZepToolsService:
if not self.api_key: if not self.api_key:
raise ValueError("ZEP_API_KEY 未配置") raise ValueError("ZEP_API_KEY 未配置")
self.client = Zep(api_key=self.api_key) self.graph_memory = create_graph_memory_adapter(api_key=self.api_key)
self.client = getattr(self.graph_memory, 'raw_client', None)
# LLM客户端用于InsightForge生成子问题 # LLM客户端用于InsightForge生成子问题
self._llm_client = llm_client self._llm_client = llm_client
logger.info(t("console.zepToolsInitialized")) logger.info(t("console.zepToolsInitialized"))
@ -488,7 +487,7 @@ class ZepToolsService:
# 尝试使用Zep Cloud Search API # 尝试使用Zep Cloud Search API
try: try:
search_results = self._call_with_retry( search_results = self._call_with_retry(
func=lambda: self.client.graph.search( func=lambda: self.graph_memory.search(
graph_id=graph_id, graph_id=graph_id,
query=query, query=query,
limit=limit, limit=limit,
@ -659,7 +658,7 @@ class ZepToolsService:
""" """
logger.info(t("console.fetchingAllNodes", graphId=graph_id)) logger.info(t("console.fetchingAllNodes", graphId=graph_id))
nodes = fetch_all_nodes(self.client, graph_id) nodes = self.graph_memory.get_all_nodes(graph_id)
result = [] result = []
for node in nodes: for node in nodes:
@ -688,7 +687,7 @@ class ZepToolsService:
""" """
logger.info(t("console.fetchingAllEdges", graphId=graph_id)) logger.info(t("console.fetchingAllEdges", graphId=graph_id))
edges = fetch_all_edges(self.client, graph_id) edges = self.graph_memory.get_all_edges(graph_id)
result = [] result = []
for edge in edges: for edge in edges:
@ -727,7 +726,7 @@ class ZepToolsService:
try: try:
node = self._call_with_retry( node = self._call_with_retry(
func=lambda: self.client.graph.node.get(uuid_=node_uuid), func=lambda: self.graph_memory.get_node(node_uuid),
operation_name=t("console.fetchNodeDetailOp", uuid=node_uuid[:8]) operation_name=t("console.fetchNodeDetailOp", uuid=node_uuid[:8])
) )

View File

@ -130,6 +130,8 @@ except ImportError as e:
print("请先安装: pip install oasis-ai camel-ai") print("请先安装: pip install oasis-ai camel-ai")
sys.exit(1) sys.exit(1)
from action_logger import PlatformActionLogger
# IPC相关常量 # IPC相关常量
IPC_COMMANDS_DIR = "ipc_commands" IPC_COMMANDS_DIR = "ipc_commands"
@ -417,6 +419,9 @@ class RedditSimulationRunner:
self.env = None self.env = None
self.agent_graph = None self.agent_graph = None
self.ipc_handler = None self.ipc_handler = None
self.action_logger = PlatformActionLogger("reddit", self.simulation_dir)
self._last_synced_trace_rowid = 0
self._synced_actions_count = 0
def _load_config(self) -> Dict[str, Any]: def _load_config(self) -> Dict[str, Any]:
"""加载配置文件""" """加载配置文件"""
@ -520,6 +525,68 @@ class RedditSimulationRunner:
return active_agents return active_agents
def _get_agent_name(self, agent_id: int) -> str:
"""Return the configured entity name for an OASIS agent."""
for agent_config in self.config.get("agent_configs", []):
if agent_config.get("agent_id") == agent_id:
return agent_config.get("entity_name") or f"Agent {agent_id}"
return f"Agent {agent_id}"
def _sync_trace_actions(self, round_num: int) -> int:
"""Mirror new OASIS trace rows into Mirofish action logs."""
db_path = self._get_db_path()
if not os.path.exists(db_path):
return 0
try:
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
cursor.execute(
"""
SELECT rowid, user_id, created_at, action, info
FROM trace
WHERE rowid > ?
ORDER BY rowid ASC
""",
(self._last_synced_trace_rowid,),
)
rows = cursor.fetchall()
conn.close()
except Exception as e:
print(f" 警告: 无法同步 trace 到 actions 日志: {e}")
return 0
synced_actions = 0
ignored_actions = {"sign_up"}
for rowid, user_id, created_at, action, info_json in rows:
self._last_synced_trace_rowid = max(self._last_synced_trace_rowid, rowid)
if action in ignored_actions:
continue
try:
action_args = json.loads(info_json) if info_json else {}
except json.JSONDecodeError:
action_args = {"raw_info": info_json}
agent_id = int(user_id) if user_id is not None else 0
action_type = str(action or "").upper()
self.action_logger.log_action(
round_num=round_num,
agent_id=agent_id,
agent_name=self._get_agent_name(agent_id),
action_type=action_type,
action_args=action_args,
result=json.dumps(action_args, ensure_ascii=False) if action_args else None,
success=True,
)
synced_actions += 1
self._synced_actions_count += 1
return synced_actions
async def run(self, max_rounds: int = None): async def run(self, max_rounds: int = None):
"""运行Reddit模拟 """运行Reddit模拟
@ -587,6 +654,7 @@ class RedditSimulationRunner:
# 初始化IPC处理器 # 初始化IPC处理器
self.ipc_handler = IPCHandler(self.simulation_dir, self.env, self.agent_graph) self.ipc_handler = IPCHandler(self.simulation_dir, self.env, self.agent_graph)
self.ipc_handler.update_status("running") self.ipc_handler.update_status("running")
self.action_logger.log_simulation_start(self.config)
# 执行初始事件 # 执行初始事件
event_config = self.config.get("event_config", {}) event_config = self.config.get("event_config", {})
@ -617,7 +685,8 @@ class RedditSimulationRunner:
if initial_actions: if initial_actions:
await self.env.step(initial_actions) await self.env.step(initial_actions)
print(f" 已发布 {len(initial_actions)} 条初始帖子") synced = self._sync_trace_actions(round_num=0)
print(f" 已发布 {len(initial_actions)} 条初始帖子,同步 {synced} 条动作")
# 主模拟循环 # 主模拟循环
print("\n开始模拟循环...") print("\n开始模拟循环...")
@ -632,7 +701,9 @@ class RedditSimulationRunner:
self.env, simulated_hour, round_num self.env, simulated_hour, round_num
) )
self.action_logger.log_round_start(round_num + 1, simulated_hour)
if not active_agents: if not active_agents:
self.action_logger.log_round_end(round_num + 1, 0)
continue continue
actions = { actions = {
@ -642,6 +713,9 @@ class RedditSimulationRunner:
await self.env.step(actions) await self.env.step(actions)
synced = self._sync_trace_actions(round_num=round_num + 1)
self.action_logger.log_round_end(round_num + 1, synced)
if (round_num + 1) % 10 == 0 or round_num == 0: if (round_num + 1) % 10 == 0 or round_num == 0:
elapsed = (datetime.now() - start_time).total_seconds() elapsed = (datetime.now() - start_time).total_seconds()
progress = (round_num + 1) / total_rounds * 100 progress = (round_num + 1) / total_rounds * 100
@ -650,6 +724,8 @@ class RedditSimulationRunner:
f"- {len(active_agents)} agents active " f"- {len(active_agents)} agents active "
f"- elapsed: {elapsed:.1f}s") f"- elapsed: {elapsed:.1f}s")
self.action_logger.log_simulation_end(total_rounds, self._synced_actions_count)
total_elapsed = (datetime.now() - start_time).total_seconds() total_elapsed = (datetime.now() - start_time).total_seconds()
print(f"\n模拟循环完成!") print(f"\n模拟循环完成!")
print(f" - 总耗时: {total_elapsed:.1f}秒") print(f" - 总耗时: {total_elapsed:.1f}秒")

View File

@ -12,3 +12,36 @@ services:
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- ./backend/uploads:/app/backend/uploads - ./backend/uploads:/app/backend/uploads
graphiti-falkordb:
image: falkordb/falkordb:latest
container_name: graphiti-falkordb
profiles:
- graphiti
volumes:
- graphiti_falkordb_data:/data
restart: unless-stopped
graphiti-bridge:
build:
context: .
dockerfile: graphiti_bridge/Dockerfile
image: mirofish-graphiti-bridge:latest
container_name: graphiti-bridge
profiles:
- graphiti
environment:
OPENAI_API_KEY: ${LLM_API_KEY}
OPENAI_BASE_URL: ${LLM_BASE_URL:-https://api.openai.com/v1}
MODEL_NAME: ${GRAPHITI_MODEL_NAME:-${LLM_MODEL_NAME:-gpt-5.4-mini}}
EMBEDDING_MODEL_NAME: ${GRAPHITI_EMBEDDING_MODEL_NAME:-text-embedding-3-small}
FALKORDB_HOST: graphiti-falkordb
FALKORDB_PORT: 6379
depends_on:
- graphiti-falkordb
ports:
- "127.0.0.1:8008:8008"
restart: unless-stopped
volumes:
graphiti_falkordb_data:

View File

@ -0,0 +1,211 @@
# On-Premise Graph Memory
This document describes the intended on-premise graph-memory setup for MiroFish. The goal is to keep the default Zep Cloud setup unchanged while allowing operators to run graph memory locally with Graphiti in a separate Docker service.
## Target architecture
MiroFish should not vendor or fork Graphiti. Graphiti stays an external service and is consumed through the graph-memory adapter layer.
```text
MiroFish application
-> graph-memory adapter
-> Zep Cloud adapter, default
-> Graphiti bridge adapter, optional on-premise mode
-> Graphiti bridge container
-> Graphiti Core from the public Graphiti package
-> FalkorDB container for graph storage
```
This separation keeps the MiroFish codebase small, avoids dependency conflicts with the existing backend, and makes the deployment model explicit. The Graphiti bridge container owns the Graphiti Python dependencies. MiroFish only calls the bridge through HTTP.
## Repository responsibilities
The MiroFish repository contains:
- the graph-memory provider interface;
- the default Zep Cloud implementation;
- the optional Graphiti bridge implementation;
- the `graphiti_bridge` service wrapper;
- Docker Compose wiring for the bridge and FalkorDB;
- environment variables selecting the active backend.
The MiroFish repository does not contain:
- a copied Graphiti source tree;
- local modifications to the Graphiti upstream project;
- a hard dependency on on-premise graph memory for default users.
## Backend selection
Zep Cloud remains the default backend. Existing installations continue to work with the current configuration.
```env
GRAPH_MEMORY_BACKEND=zep_cloud
ZEP_API_KEY=your_zep_api_key_here
```
On-premise mode is enabled explicitly:
```env
GRAPH_MEMORY_BACKEND=graphiti_bridge
GRAPHITI_BRIDGE_URL=http://graphiti-bridge:8008
GRAPHITI_MODEL_NAME=gpt-5.4-mini
GRAPHITI_EMBEDDING_MODEL_NAME=text-embedding-3-small
```
The bridge receives the LLM credentials through the existing OpenAI-compatible variables:
```env
LLM_API_KEY=your_api_key_here
LLM_BASE_URL=https://api.openai.com/v1
LLM_MODEL_NAME=gpt-5.4-mini
```
## Docker services
The on-premise graph-memory stack uses two additional services:
| Service | Purpose |
| --- | --- |
| `graphiti-bridge` | HTTP compatibility layer used by MiroFish. It imports `graphiti-core` and translates MiroFish graph-memory calls to Graphiti operations. |
| `graphiti-falkordb` | Local graph database used by Graphiti. Data is persisted in the `graphiti_falkordb_data` Docker volume. |
Start the graph-memory services with the `graphiti` profile:
```bash
docker compose --profile graphiti up -d graphiti-falkordb graphiti-bridge
```
Then start or restart MiroFish:
```bash
docker compose build mirofish
docker compose up -d mirofish
```
For a full local stack, run:
```bash
docker compose --profile graphiti up -d
```
## Expected runtime URLs
Inside Docker Compose:
```text
MiroFish backend -> http://graphiti-bridge:8008
Graphiti bridge -> graphiti-falkordb:6379
```
From the host machine:
```text
MiroFish frontend: http://localhost:3000
MiroFish backend: http://localhost:5001
Graphiti bridge: http://127.0.0.1:8008
```
The bridge is bound to `127.0.0.1` on the host so it is not exposed on the network by default.
## Health checks
Check the bridge:
```bash
curl http://127.0.0.1:8008/health
```
Check the MiroFish backend:
```bash
curl http://localhost:5001/health
```
Expected bridge response:
```json
{
"service": "graphiti-bridge",
"status": "ok"
}
```
## Smoke-test flow
After startup, verify the complete path through the application rather than only checking container health.
1. Open `http://localhost:3000`.
2. Create a new project.
3. Add a small but meaningful source document with several named actors and relationships.
4. Generate the ontology.
5. Build the graph.
6. Confirm that the graph contains more than placeholder nodes and edges.
7. Create a simulation.
8. Run at least one round.
9. Confirm that the simulation can read from graph memory and complete without a Zep Cloud key.
For API-level validation, the same flow should cover ontology generation, graph build, simulation creation, simulation start, simulation status, graph readback, and report generation.
## Operational notes
- Use Zep Cloud for the default upstream-compatible mode.
- Use `graphiti_bridge` for local, no-cloud graph memory.
- Keep Graphiti isolated in its own container to avoid dependency conflicts.
- Keep the bridge API narrow and aligned with the MiroFish graph-memory adapter interface.
- Store graph data in the Docker volume `graphiti_falkordb_data`.
- Remove the volume only when local graph-memory data should be discarded.
- Do not expose the bridge port publicly unless an operator has added authentication and network controls.
## Fresh MacBook installation notes
For a clean on-premise installation on a new MacBook, the operator should install Docker Desktop, clone the MiroFish fork or the target branch, create `.env`, select `GRAPH_MEMORY_BACKEND=graphiti_bridge`, and start the Compose stack with the `graphiti` profile.
The installation should not require a Zep Cloud account in on-premise mode. It still requires an OpenAI-compatible LLM endpoint because Graphiti extraction and embeddings use model calls.
Recommended `.env` shape for on-premise testing:
```env
LLM_API_KEY=your_api_key_here
LLM_BASE_URL=https://api.openai.com/v1
LLM_MODEL_NAME=gpt-5.4-mini
GRAPH_MEMORY_BACKEND=graphiti_bridge
GRAPHITI_BRIDGE_URL=http://graphiti-bridge:8008
GRAPHITI_MODEL_NAME=gpt-5.4-mini
GRAPHITI_EMBEDDING_MODEL_NAME=text-embedding-3-small
```
## Instructions for an AI installation agent
Use this section as the direct installation brief for an automated coding or operations agent.
Objective: install and verify MiroFish with local on-premise graph memory. Do not use Zep Cloud. Do not copy Graphiti source code into MiroFish. Run Graphiti through the existing `graphiti_bridge` Docker service and persist graph data in FalkorDB.
Steps:
1. Confirm Docker Desktop or Docker Engine is installed and running.
2. Clone the target MiroFish repository and checkout the on-premise graph-memory branch.
3. Create `.env` from `.env.example`.
4. Set `GRAPH_MEMORY_BACKEND=graphiti_bridge`.
5. Set `GRAPHITI_BRIDGE_URL=http://graphiti-bridge:8008`.
6. Set `LLM_API_KEY`, `LLM_BASE_URL`, and `LLM_MODEL_NAME` to an OpenAI-compatible model endpoint.
7. Set `GRAPHITI_MODEL_NAME` and `GRAPHITI_EMBEDDING_MODEL_NAME` if different from the defaults.
8. Start the stack with `docker compose --profile graphiti up -d --build`.
9. Verify `http://127.0.0.1:8008/health` returns status `ok`.
10. Verify `http://localhost:5001/health` returns successfully.
11. Open `http://localhost:3000` and run a complete graph-build and simulation smoke test.
12. Inspect the graph result and reject the installation if only placeholder or generic nodes are created from a rich source document.
13. Record the tested commit, `.env` backend mode, container status, and smoke-test result.
Acceptance criteria:
- The application starts from Docker Compose.
- No Zep Cloud API key is required in `graphiti_bridge` mode.
- The bridge health check succeeds.
- A project can build a graph from source documents.
- Graph nodes and edges reflect the document content.
- A simulation can be created and started against the local graph memory.
- The setup remains compatible with the default Zep Cloud mode when `GRAPH_MEMORY_BACKEND=zep_cloud` is selected.
Do not mark the installation complete if the graph build produces only a few generic nodes from a rich source document, if the simulation fails during startup, or if the application still requires `ZEP_API_KEY` while `GRAPH_MEMORY_BACKEND=graphiti_bridge` is selected.

View File

@ -0,0 +1,8 @@
FROM python:3.12-slim
WORKDIR /app
COPY graphiti_bridge/requirements.txt ./requirements.txt
RUN pip install --no-cache-dir -r requirements.txt
COPY graphiti_bridge/app.py ./app.py
EXPOSE 8008
CMD ["python", "app.py"]

264
graphiti_bridge/app.py Normal file
View File

@ -0,0 +1,264 @@
from __future__ import annotations
import asyncio
import os
from datetime import datetime, timezone
from typing import Any, Optional
from flask import Flask, jsonify, request
from pydantic import BaseModel, Field
from graphiti_core.driver.falkordb_driver import FalkorDriver
from graphiti_core.embedder.openai import OpenAIEmbedder, OpenAIEmbedderConfig
from graphiti_core.graphiti import Graphiti
from graphiti_core.llm_client.config import LLMConfig
from graphiti_core.llm_client.openai_client import OpenAIClient
from graphiti_core.nodes import EpisodeType
app = Flask(__name__)
FALKORDB_HOST = os.environ.get("FALKORDB_HOST", "graphiti-falkordb")
FALKORDB_PORT = int(os.environ.get("FALKORDB_PORT", "6379"))
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY") or os.environ.get("LLM_API_KEY")
OPENAI_BASE_URL = os.environ.get("OPENAI_BASE_URL") or os.environ.get("LLM_BASE_URL", "https://api.openai.com/v1")
MODEL_NAME = os.environ.get("MODEL_NAME") or os.environ.get("GRAPHITI_MODEL_NAME", "gpt-5.4-mini")
EMBEDDING_MODEL_NAME = os.environ.get("EMBEDDING_MODEL_NAME") or os.environ.get("GRAPHITI_EMBEDDING_MODEL_NAME", "text-embedding-3-small")
ONTOLOGIES: dict[str, dict[str, Any]] = {}
INDICES_INITIALIZED: set[str] = set()
RESERVED_NAMES = {"uuid", "name", "group_id", "name_embedding", "summary", "created_at"}
def run(coro):
return asyncio.run(coro)
def safe_attr_name(attr_name: str) -> str:
if attr_name.lower() in RESERVED_NAMES:
return f"entity_{attr_name}"
return attr_name
def build_ontology(ontology: dict[str, Any]) -> dict[str, Any]:
entity_types: dict[str, type[BaseModel]] = {}
entity_names: list[str] = []
for entity_def in ontology.get("entity_types", []):
name = entity_def["name"]
description = entity_def.get("description", f"A {name} entity.")
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"])
attrs[attr_name] = Field(default=None, description=attr_def.get("description", attr_name))
annotations[attr_name] = Optional[str]
attrs["__annotations__"] = annotations
entity_types[name] = type(name, (BaseModel,), attrs)
entity_names.append(name)
edge_types: dict[str, type[BaseModel]] = {}
edge_type_map: dict[tuple[str, str], list[str]] = {}
for edge_def in ontology.get("edge_types", []):
name = edge_def["name"]
description = edge_def.get("description", f"A {name} relationship.")
edge_types[name] = type(name, (BaseModel,), {"__doc__": description, "__annotations__": {}})
for st in edge_def.get("source_targets", []):
source = st.get("source", "Entity")
target = st.get("target", "Entity")
edge_type_map.setdefault((source, target), []).append(name)
if edge_types and not edge_type_map:
labels = entity_names + ["Entity"]
edge_names = list(edge_types.keys())
edge_type_map = {(source, target): edge_names for source in labels for target in labels}
return {"entity_types": entity_types or None, "edge_types": edge_types or None, "edge_type_map": edge_type_map or None}
async def graphiti(graph_id: str) -> Graphiti:
if not OPENAI_API_KEY:
raise RuntimeError("OPENAI_API_KEY/LLM_API_KEY is not configured")
os.environ.setdefault("OPENAI_API_KEY", OPENAI_API_KEY)
os.environ.setdefault("OPENAI_BASE_URL", OPENAI_BASE_URL)
os.environ.setdefault("MODEL_NAME", MODEL_NAME)
os.environ.setdefault("EMBEDDING_MODEL_NAME", EMBEDDING_MODEL_NAME)
driver = FalkorDriver(host=FALKORDB_HOST, port=FALKORDB_PORT, database=graph_id)
return Graphiti(graph_driver=driver)
async def ensure_indices(graph_id: str) -> None:
if graph_id in INDICES_INITIALIZED:
return
client = await graphiti(graph_id)
await client.build_indices_and_constraints()
INDICES_INITIALIZED.add(graph_id)
async def query_graph(graph_id: str, query: str, **params: Any) -> list[dict[str, Any]]:
driver = FalkorDriver(host=FALKORDB_HOST, port=FALKORDB_PORT, database=graph_id)
rows, _, _ = await driver.execute_query(query, **params)
return rows
def edge_from_obj(edge: Any) -> dict[str, Any]:
return {
"uuid": getattr(edge, "uuid", "") or 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", ""),
"attributes": getattr(edge, "attributes", {}) or {},
"created_at": str(getattr(edge, "created_at", "") or "") or None,
"valid_at": str(getattr(edge, "valid_at", "") or "") or None,
"invalid_at": str(getattr(edge, "invalid_at", "") or "") or None,
"expired_at": str(getattr(edge, "expired_at", "") or "") or None,
}
@app.get("/health")
def health():
return jsonify({"status": "ok", "service": "graphiti-bridge"})
@app.post("/graphs")
def create_graph():
payload = request.get_json(force=True)
graph_id = payload["graph_id"]
run(ensure_indices(graph_id))
return jsonify({"graph_id": graph_id})
@app.post("/graphs/<graph_id>/ontology")
def set_ontology(graph_id: str):
ONTOLOGIES[graph_id] = build_ontology(request.get_json(force=True) or {})
return jsonify({"ok": True})
@app.post("/graphs/<graph_id>/episodes")
def add_episodes(graph_id: str):
payload = request.get_json(force=True)
chunks = payload.get("chunks") or [payload.get("text", "")]
async def add_all():
await ensure_indices(graph_id)
client = await graphiti(graph_id)
ontology = ONTOLOGIES.get(graph_id, {})
out = []
for index, chunk in enumerate(chunks, 1):
result = await client.add_episode(
name=f"mirofish-chunk-{index}",
episode_body=chunk,
source_description="Mirofish text episode",
reference_time=datetime.now(timezone.utc),
source=EpisodeType.text,
group_id=graph_id,
entity_types=ontology.get("entity_types"),
edge_types=ontology.get("edge_types"),
edge_type_map=ontology.get("edge_type_map"),
custom_extraction_instructions="Extract actors and relationships relevant to the simulation. Prefer provided ontology labels when supported by the text.",
)
episode_uuid = getattr(result.episode, "uuid", None) or getattr(result.episode, "uuid_", None)
out.append({"uuid": episode_uuid, "processed": True})
return out
return jsonify({"episodes": run(add_all())})
@app.get("/graphs/<graph_id>/episodes/<episode_uuid>")
def get_episode(graph_id: str, episode_uuid: str):
return jsonify({"uuid": episode_uuid, "processed": True})
@app.get("/graphs/<graph_id>/nodes")
def get_nodes(graph_id: str):
rows = run(query_graph(graph_id, """
MATCH (n:Entity)
RETURN n.uuid AS uuid, n.name AS name, labels(n) AS labels, n.summary AS summary, n.created_at AS created_at
LIMIT 2000
"""))
return jsonify({"nodes": rows})
@app.get("/graphs/<graph_id>/edges")
def get_edges(graph_id: str):
rows = run(query_graph(graph_id, """
MATCH (a:Entity)-[r]->(b:Entity)
RETURN r.uuid AS uuid, type(r) AS name, r.fact AS fact,
a.uuid AS source_node_uuid, b.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
LIMIT 5000
"""))
return jsonify({"edges": rows})
@app.get("/nodes/<node_uuid>")
def get_node(node_uuid: str):
graph_id = request.args.get("graph_id")
if not graph_id:
return jsonify({"node": None})
rows = run(query_graph(graph_id, """
MATCH (n:Entity {uuid: $uuid})
RETURN n.uuid AS uuid, n.name AS name, labels(n) AS labels, n.summary AS summary, n.created_at AS created_at
LIMIT 1
""", uuid=node_uuid))
return jsonify({"node": rows[0] if rows else None})
@app.get("/nodes/<node_uuid>/edges")
def get_node_edges(node_uuid: str):
graph_id = request.args.get("graph_id")
if not graph_id:
return jsonify({"edges": []})
rows = run(query_graph(graph_id, """
MATCH (a:Entity)-[r]->(b:Entity)
WHERE a.uuid = $uuid OR b.uuid = $uuid
RETURN r.uuid AS uuid, type(r) AS name, r.fact AS fact,
a.uuid AS source_node_uuid, b.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
LIMIT 5000
""", uuid=node_uuid))
return jsonify({"edges": rows})
@app.post("/graphs/<graph_id>/search")
def search(graph_id: str):
payload = request.get_json(force=True)
query = payload.get("query", "")
limit = int(payload.get("limit", 10))
scope = payload.get("scope", "edges")
async def do_search():
client = await graphiti(graph_id)
edges = [] if scope == "nodes" else [edge_from_obj(edge) for edge in await client.search(query=query, group_ids=[graph_id], num_results=limit)]
nodes = []
if scope in {"nodes", "both"}:
terms = [term.lower() for term in query.split() if len(term) > 1]
rows = await query_graph(graph_id, """
MATCH (n:Entity)
RETURN n.uuid AS uuid, n.name AS name, labels(n) AS labels, n.summary AS summary, n.created_at AS created_at
LIMIT 2000
""")
scored = []
for row in rows:
text = f"{row.get('name', '')} {row.get('summary', '')}".lower()
score = sum(1 for term in terms if term in text)
if query.lower() in text:
score += 10
if score:
scored.append((score, row))
scored.sort(key=lambda item: item[0], reverse=True)
nodes = [row for _, row in scored[:limit]]
return {"edges": edges, "nodes": nodes}
return jsonify(run(do_search()))
@app.delete("/graphs/<graph_id>")
def delete_graph(graph_id: str):
run(query_graph(graph_id, "MATCH (n) DETACH DELETE n"))
ONTOLOGIES.pop(graph_id, None)
INDICES_INITIALIZED.discard(graph_id)
return jsonify({"ok": True})
if __name__ == "__main__":
app.run(host="0.0.0.0", port=int(os.environ.get("PORT", "8008")))

View File

@ -0,0 +1,4 @@
flask>=3.0.0
graphiti-core[falkordb]==0.29.2
openai>=2.0.0
pydantic>=2.0.0