brain-university-api / graph /temporal_graph.py
jang0294's picture
Upload folder using huggingface_hub
7b0da0b verified
Raw
History Blame Contribute Delete
12.2 kB
"""
Thin wrapper around graphiti-core + FalkorDB.
Public surface:
get_graph() → singleton Graphiti instance, configured for our env
add_episode(...) → ingest a piece of text with timestamp + source label
search_nodes(query) → semantic search over entity nodes
search_facts(query) → search over (entity-relation-entity) facts
snapshot_at(t) → return all edges valid at time t (for UI time-slider)
Why FalkorDB: lightweight Redis-based graph DB, runs in 1 Docker container,
< 100 MB RAM, fully Cypher-compatible. Swap to Neo4j by changing FALKOR_URI
to a bolt:// URL — no other code changes.
"""
from __future__ import annotations
import os
import asyncio
from datetime import datetime, timezone
from functools import lru_cache
from typing import Any
# Graphiti is heavy — defer import until used so the rest of the codebase
# can import this module without requiring Graphiti to be installed.
_graphiti = None
FALKOR_URI = os.environ.get("FALKOR_URI", "redis://localhost:6379")
GROUP_ID = os.environ.get("GRAPHITI_GROUP", "gemma_eng_wiki")
_driver_cache: dict = {}
def _get_driver(group_id: str | None = None):
"""Driver per FalkorDB graph. Graphiti's add_episode uses group_id AS the
graph name (not as a property), so reads must target the same graph."""
db = group_id or GROUP_ID
if db in _driver_cache:
return _driver_cache[db]
from graphiti_core.driver.falkordb_driver import FalkorDriver
host = FALKOR_URI.replace("redis://", "").split(":")[0]
port = int(FALKOR_URI.replace("redis://", "").split(":")[1]) if ":" in FALKOR_URI else 6379
_driver_cache[db] = FalkorDriver(host=host, port=port, database=db)
return _driver_cache[db]
def _build_llm_client():
"""LLM for entity extraction. Anthropic preferred, OpenAI fallback."""
if os.environ.get("ANTHROPIC_API_KEY"):
from graphiti_core.llm_client.anthropic_client import AnthropicClient
from graphiti_core.llm_client.config import LLMConfig
return AnthropicClient(config=LLMConfig(
model="claude-haiku-4-5",
api_key=os.environ["ANTHROPIC_API_KEY"],
))
if os.environ.get("OPENAI_API_KEY"):
return None # Graphiti default OpenAI client picks it up
return None
def _build_embedder():
"""Embedder for vector search. Anthropic has no embeddings API, so we
pick the first available alternative provider in priority order:
Voyage (cheap) → Gemini (free tier) → OpenAI."""
if os.environ.get("VOYAGE_API_KEY"):
from graphiti_core.embedder.voyage import VoyageAIEmbedder, VoyageAIEmbedderConfig
return VoyageAIEmbedder(config=VoyageAIEmbedderConfig(
api_key=os.environ["VOYAGE_API_KEY"], embedding_model="voyage-3-lite",
))
if os.environ.get("GOOGLE_API_KEY") or os.environ.get("GEMINI_API_KEY"):
from graphiti_core.embedder.gemini import GeminiEmbedder, GeminiEmbedderConfig
return GeminiEmbedder(config=GeminiEmbedderConfig(
api_key=os.environ.get("GOOGLE_API_KEY") or os.environ["GEMINI_API_KEY"],
embedding_model="gemini-embedding-001",
))
if os.environ.get("OPENAI_API_KEY"):
from graphiti_core.embedder.openai import OpenAIEmbedder, OpenAIEmbedderConfig
return OpenAIEmbedder(config=OpenAIEmbedderConfig(
api_key=os.environ["OPENAI_API_KEY"], embedding_model="text-embedding-3-small",
))
return None
def get_graph():
"""Singleton Graphiti instance backed by FalkorDB.
Requires:
- LLM key: ANTHROPIC_API_KEY (preferred) or OPENAI_API_KEY
- Embedder key: VOYAGE_API_KEY / GOOGLE_API_KEY / OPENAI_API_KEY
(Anthropic has no embeddings API — must use a different provider.)
"""
global _graphiti
if _graphiti is not None:
return _graphiti
llm = _build_llm_client()
embedder = _build_embedder()
if embedder is None:
raise RuntimeError(
"No embedder key found. Set ONE of:\n"
" GOOGLE_API_KEY (free tier — easiest, https://aistudio.google.com/apikey)\n"
" VOYAGE_API_KEY (cheap, https://www.voyageai.com)\n"
" OPENAI_API_KEY (paid)\n"
"Anthropic has no embeddings API, so even with ANTHROPIC_API_KEY set "
"Graphiti needs a separate provider for vector search."
)
from graphiti_core import Graphiti
driver = _get_driver()
kwargs = {"graph_driver": driver, "embedder": embedder}
if llm is not None:
kwargs["llm_client"] = llm
# Cross-encoder (reranker) — Graphiti defaults to OpenAI. Match to whichever
# provider's key is set, so we don't silently require OPENAI_API_KEY.
cross_encoder = None
if os.environ.get("GOOGLE_API_KEY") or os.environ.get("GEMINI_API_KEY"):
from graphiti_core.cross_encoder.gemini_reranker_client import GeminiRerankerClient
from graphiti_core.llm_client.config import LLMConfig
cross_encoder = GeminiRerankerClient(config=LLMConfig(
api_key=os.environ.get("GOOGLE_API_KEY") or os.environ["GEMINI_API_KEY"],
model="gemini-2.0-flash-exp",
))
elif os.environ.get("OPENAI_API_KEY"):
pass # default works
else:
# No reranker provider — try BGE local model (sentence-transformers)
try:
from graphiti_core.cross_encoder.bge_reranker_client import BGERerankerClient
cross_encoder = BGERerankerClient()
except Exception:
pass
if cross_encoder is not None:
kwargs["cross_encoder"] = cross_encoder
_graphiti = Graphiti(**kwargs)
return _graphiti
_loop = None
def _run(coro):
"""Run an async call on a single persistent loop. asyncio.run() per call
closes the loop, which kills the embedder/LLM clients Graphiti caches."""
global _loop
try:
running = asyncio.get_running_loop()
# Already inside async context — use nest_asyncio
import nest_asyncio
nest_asyncio.apply()
return running.run_until_complete(coro)
except RuntimeError:
pass
if _loop is None or _loop.is_closed():
_loop = asyncio.new_event_loop()
asyncio.set_event_loop(_loop)
return _loop.run_until_complete(coro)
# ── Public API ──────────────────────────────────────────────────────────────
def add_episode(name: str, episode_body: str, source: str = "text",
reference_time: datetime | None = None,
group_id: str | None = None,
source_description: str = "") -> dict:
if not (os.environ.get("ANTHROPIC_API_KEY") or os.environ.get("OPENAI_API_KEY")):
raise RuntimeError(
"Graphiti needs an LLM key for entity extraction. "
"Set ANTHROPIC_API_KEY (preferred) or OPENAI_API_KEY before ingesting."
)
"""Ingest a piece of text. Graphiti will:
1. Extract entities (with deduplication against existing)
2. Extract relationships
3. Detect contradictions with existing edges → invalidate old, add new
4. All edges get t_valid = reference_time (defaults to now)
"""
g = get_graph()
if reference_time is None:
reference_time = datetime.now(timezone.utc)
from graphiti_core.nodes import EpisodeType
type_map = {"text": EpisodeType.text, "json": EpisodeType.json,
"message": EpisodeType.message}
et = type_map.get(source, EpisodeType.text)
result = _run(g.add_episode(
name=name,
episode_body=episode_body,
source=et,
reference_time=reference_time,
group_id=group_id or GROUP_ID,
source_description=source_description,
))
return {
"episode_id": getattr(result, "episode_uuid", None),
"n_nodes_added": len(getattr(result, "nodes", []) or []),
"n_edges_added": len(getattr(result, "edges", []) or []),
"n_invalidated": len(getattr(result, "invalidated_edges", []) or []),
}
def search_nodes(query: str, limit: int = 10,
group_id: str | None = None) -> list[dict]:
"""Hybrid (semantic + BM25 + graph-distance) search over entity nodes."""
g = get_graph()
results = _run(g.search(
query=query,
num_results=limit,
group_ids=[group_id or GROUP_ID],
))
out = []
for r in results:
out.append({
"name": getattr(r, "name", ""),
"summary": getattr(r, "summary", ""),
"score": getattr(r, "score", None),
"labels": getattr(r, "labels", []),
})
return out
def search_facts(query: str, limit: int = 10,
group_id: str | None = None) -> list[dict]:
"""Search edges (facts) currently valid + their provenance."""
g = get_graph()
edges = _run(g.search(
query=query,
num_results=limit,
group_ids=[group_id or GROUP_ID],
center_node_uuid=None,
))
out = []
for e in edges:
out.append({
"fact": getattr(e, "fact", str(e)),
"valid_at": str(getattr(e, "valid_at", "")),
"invalid_at": str(getattr(e, "invalid_at", "")),
"source": getattr(e, "source_node_uuid", ""),
"target": getattr(e, "target_node_uuid", ""),
})
return out
def snapshot_at(t: datetime, limit: int = 200,
group_id: str | None = None,
include_uuid: bool = True) -> dict:
"""Return all edges that were valid at time `t`.
Used by the time-slider UI.
When include_uuid=True, also returns per-edge `uuid` so the live UI can
diff snapshots and animate newly-added / newly-invalidated edges.
"""
gid = group_id or GROUP_ID
driver = _get_driver(gid)
uuid_col = ", r.uuid AS uuid" if include_uuid else ""
t_iso = t.isoformat()
# Graphiti uses group_id AS database name, so the entire current graph
# already represents this group — no per-row filter needed.
cypher = f"""
MATCH (s)-[r:RELATES_TO]->(o)
WHERE r.valid_at <= '{t_iso}'
AND (r.invalid_at IS NULL OR r.invalid_at = '' OR r.invalid_at > '{t_iso}')
RETURN s.name AS source, r.fact AS fact, o.name AS target,
r.valid_at AS valid_at, r.invalid_at AS invalid_at,
r.created_at AS created_at{uuid_col}
ORDER BY r.created_at DESC
LIMIT {int(limit)}
"""
try:
records, headers, _ = _run(driver.execute_query(cypher))
edges = [dict(rec) for rec in records]
except Exception as e:
return {"error": str(e), "edges": []}
return {"edges": edges, "as_of": t.isoformat()}
def recent_edges(since_seconds: int = 600, limit: int = 500,
group_id: str | None = None) -> dict:
"""Return all edges (active + invalidated) plus a 'is_recent' flag for
edges created within the last `since_seconds`. Used by the live-brain UI
to pulse newly-formed connections.
"""
from datetime import timedelta
gid = group_id or GROUP_ID
driver = _get_driver(gid)
cutoff_ms = int((datetime.now(timezone.utc) - timedelta(seconds=since_seconds)).timestamp() * 1000)
cypher = f"""
MATCH (s)-[r:RELATES_TO]->(o)
RETURN s.name AS source, r.fact AS fact, o.name AS target,
r.valid_at AS valid_at, r.invalid_at AS invalid_at,
r.created_at AS created_at, r.uuid AS uuid
ORDER BY r.created_at DESC
LIMIT {int(limit)}
"""
try:
records, _, _ = _run(driver.execute_query(cypher))
edges = []
for rec in records:
d = dict(rec)
ca = d.get("created_at")
d["is_recent"] = bool(ca) and (int(ca) if isinstance(ca, (int, float)) else 0) >= cutoff_ms
d["is_invalidated"] = bool(d.get("invalid_at")) and d.get("invalid_at") not in ("", "None", "null")
edges.append(d)
except Exception as e:
return {"error": str(e), "edges": []}
return {"edges": edges, "cutoff_ms": cutoff_ms}