Spaces:
Running
Running
| """ | |
| 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} | |