docugraph 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- docugraph/__init__.py +24 -0
- docugraph/_version.py +28 -0
- docugraph/agents/__init__.py +27 -0
- docugraph/agents/coding_agent.py +532 -0
- docugraph/agents/tools/__init__.py +62 -0
- docugraph/agents/tools/graph_query.py +203 -0
- docugraph/agents/tools/memory_ops.py +305 -0
- docugraph/agents/tools/search_code.py +305 -0
- docugraph/agents/tools/search_docs.py +148 -0
- docugraph/core/__init__.py +34 -0
- docugraph/core/config.py +183 -0
- docugraph/core/embeddings.py +394 -0
- docugraph/core/llm.py +892 -0
- docugraph/core/models.py +145 -0
- docugraph/ingestion/__init__.py +64 -0
- docugraph/ingestion/api_docs.py +646 -0
- docugraph/ingestion/chunker.py +382 -0
- docugraph/ingestion/crawler.py +305 -0
- docugraph/ingestion/git_indexer.py +556 -0
- docugraph/ingestion/local_files.py +516 -0
- docugraph/interfaces/__init__.py +1 -0
- docugraph/interfaces/cli.py +983 -0
- docugraph/interfaces/mcp_server.py +918 -0
- docugraph/retrieval/__init__.py +54 -0
- docugraph/retrieval/context_builder.py +522 -0
- docugraph/retrieval/hybrid_search.py +534 -0
- docugraph/retrieval/reranker.py +532 -0
- docugraph/storage/__init__.py +14 -0
- docugraph/storage/graph_store.py +602 -0
- docugraph/storage/memory_store.py +529 -0
- docugraph/storage/vector_store.py +367 -0
- docugraph-0.1.0.dist-info/METADATA +354 -0
- docugraph-0.1.0.dist-info/RECORD +37 -0
- docugraph-0.1.0.dist-info/WHEEL +4 -0
- docugraph-0.1.0.dist-info/entry_points.txt +3 -0
- docugraph-0.1.0.dist-info/licenses/LICENSE +201 -0
- docugraph-0.1.0.dist-info/licenses/NOTICE +10 -0
|
@@ -0,0 +1,602 @@
|
|
|
1
|
+
"""Graph store backed by Graphiti + Kuzu for knowledge graph operations.
|
|
2
|
+
|
|
3
|
+
Supports LLM-agnostic configuration with local-first priority:
|
|
4
|
+
- Ollama (local, default if available)
|
|
5
|
+
- OpenAI (cloud)
|
|
6
|
+
- Anthropic (cloud)
|
|
7
|
+
|
|
8
|
+
Embeddings reuse the docugraph embedder (fastembed by default) via
|
|
9
|
+
GraphitiEmbedderAdapter, so graph ingestion needs no embedding service —
|
|
10
|
+
only the entity-extraction LLM (Graphiti design).
|
|
11
|
+
|
|
12
|
+
Graphiti also wants a cross-encoder for search reranking; its default
|
|
13
|
+
(OpenAIRerankerClient) requires an OpenAI key even in an Ollama-only setup,
|
|
14
|
+
and its only bundled offline alternative (BGERerankerClient) pulls in
|
|
15
|
+
sentence-transformers/torch. We supply NoopCrossEncoder so graph ingestion
|
|
16
|
+
works offline with no key and no implicit large download — consistent with
|
|
17
|
+
docugraph's opt-in reranking philosophy.
|
|
18
|
+
|
|
19
|
+
Uses the standalone LLM abstraction from docugraph.core.llm.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import asyncio
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
from datetime import UTC, datetime
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
from docugraph.core.config import EmbeddingProvider, get_config
|
|
32
|
+
from docugraph.core.embeddings import (
|
|
33
|
+
EmbeddingProviderBase,
|
|
34
|
+
OllamaEmbedder,
|
|
35
|
+
OpenAIEmbedder,
|
|
36
|
+
get_embedder,
|
|
37
|
+
)
|
|
38
|
+
from docugraph.core.llm import get_graphiti_llm_client
|
|
39
|
+
from docugraph.core.models import Entity, EntityType, RelationshipType
|
|
40
|
+
|
|
41
|
+
# Graphiti 0.30 validates injected clients with pydantic is_instance_of, so
|
|
42
|
+
# our adapters must actually subclass its base classes (duck-typing no longer
|
|
43
|
+
# passes). graphiti-core is a hard dependency; the guard only keeps the module
|
|
44
|
+
# importable if it is somehow absent, matching the lazy-degradation elsewhere.
|
|
45
|
+
try:
|
|
46
|
+
from graphiti_core.cross_encoder.client import CrossEncoderClient as _CrossEncoderBase
|
|
47
|
+
from graphiti_core.embedder.client import EmbedderClient as _EmbedderBase
|
|
48
|
+
except ImportError: # pragma: no cover - graphiti-core is a hard dependency
|
|
49
|
+
_EmbedderBase = object # type: ignore[assignment,misc]
|
|
50
|
+
_CrossEncoderBase = object # type: ignore[assignment,misc]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class NoopCrossEncoder(_CrossEncoderBase):
|
|
54
|
+
"""Graphiti CrossEncoderClient that preserves input order (no reranking).
|
|
55
|
+
|
|
56
|
+
Graphiti's default cross-encoder (OpenAIRerankerClient) needs an OpenAI
|
|
57
|
+
key, and its offline BGERerankerClient needs sentence-transformers/torch.
|
|
58
|
+
Neither fits a torch-free, key-optional setup, so this no-op keeps graph
|
|
59
|
+
search working: it returns passages in their original order with a simple
|
|
60
|
+
descending pseudo-score. Retrieval relevance still comes from the
|
|
61
|
+
embedding + graph-distance search; only the extra rerank step is skipped.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
async def rank(
|
|
65
|
+
self,
|
|
66
|
+
query: str, # noqa: ARG002 -- interface-mandated; no-op ignores the query
|
|
67
|
+
passages: list[str],
|
|
68
|
+
) -> list[tuple[str, float]]:
|
|
69
|
+
n = len(passages)
|
|
70
|
+
return [(passage, float(n - i)) for i, passage in enumerate(passages)]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class GraphitiEmbedderAdapter(_EmbedderBase):
|
|
74
|
+
"""Graphiti EmbedderClient backed by a docugraph embedder.
|
|
75
|
+
|
|
76
|
+
Lets the doc knowledge graph reuse the same embedding backend as the
|
|
77
|
+
vector store (fastembed by default) instead of requiring a separate
|
|
78
|
+
Ollama/OpenAI embedding service. Implements Graphiti's async
|
|
79
|
+
``create``/``create_batch`` interface; the sync docugraph embedder runs
|
|
80
|
+
in a worker thread.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
def __init__(self, embedder: EmbeddingProviderBase | None = None):
|
|
84
|
+
self._embedder = embedder or get_embedder()
|
|
85
|
+
|
|
86
|
+
async def create(self, input_data: Any) -> list[float]:
|
|
87
|
+
"""Embed a single text (Graphiti uses this for entity names)."""
|
|
88
|
+
# Graphiti may hand us a str or a list; normalize to the query path.
|
|
89
|
+
text = input_data if isinstance(input_data, str) else " ".join(map(str, input_data))
|
|
90
|
+
return await asyncio.to_thread(self._embedder.embed_query, text)
|
|
91
|
+
|
|
92
|
+
async def create_batch(self, input_data_list: list[str]) -> list[list[float]]:
|
|
93
|
+
"""Embed a batch of texts."""
|
|
94
|
+
if not input_data_list:
|
|
95
|
+
return []
|
|
96
|
+
return await asyncio.to_thread(self._embedder.embed, input_data_list)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _get_embedder() -> Any:
|
|
100
|
+
"""Create an embedder based on configuration.
|
|
101
|
+
|
|
102
|
+
Ollama/OpenAI use Graphiti's own service-backed embedder; every other
|
|
103
|
+
provider (fastembed default, sentence-transformers, cohere, auto)
|
|
104
|
+
reuses the docugraph embedder via GraphitiEmbedderAdapter.
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
Graphiti-compatible embedder
|
|
108
|
+
"""
|
|
109
|
+
from graphiti_core.embedder.openai import (
|
|
110
|
+
OpenAIEmbedder as _GraphitiOpenAIEmbedder,
|
|
111
|
+
)
|
|
112
|
+
from graphiti_core.embedder.openai import OpenAIEmbedderConfig
|
|
113
|
+
|
|
114
|
+
emb_config = get_config().embeddings
|
|
115
|
+
|
|
116
|
+
# Both branches ask the docugraph embedder for the model name and width
|
|
117
|
+
# rather than restating them here. The widths used to be literals -- 768 for
|
|
118
|
+
# Ollama, 1536 for OpenAI -- which is the same defect as the tables in
|
|
119
|
+
# core/embeddings.py: a second place holding a fact the model itself reports,
|
|
120
|
+
# right only for the model whoever wrote it had in mind.
|
|
121
|
+
if emb_config.provider == EmbeddingProvider.OLLAMA:
|
|
122
|
+
local = OllamaEmbedder(model_name=emb_config.model)
|
|
123
|
+
embedder_config = OpenAIEmbedderConfig(
|
|
124
|
+
api_key="ollama",
|
|
125
|
+
embedding_model=local.model_name,
|
|
126
|
+
embedding_dim=emb_config.dimensions or local.dimensions,
|
|
127
|
+
base_url="http://localhost:11434/v1",
|
|
128
|
+
)
|
|
129
|
+
return _GraphitiOpenAIEmbedder(config=embedder_config)
|
|
130
|
+
|
|
131
|
+
elif emb_config.provider == EmbeddingProvider.OPENAI:
|
|
132
|
+
api_key = os.environ.get("OPENAI_API_KEY")
|
|
133
|
+
if not api_key:
|
|
134
|
+
raise RuntimeError("OPENAI_API_KEY required for OpenAI embeddings")
|
|
135
|
+
|
|
136
|
+
cloud = OpenAIEmbedder(model_name=emb_config.model)
|
|
137
|
+
embedder_config = OpenAIEmbedderConfig(
|
|
138
|
+
api_key=api_key,
|
|
139
|
+
embedding_model=cloud.model_name,
|
|
140
|
+
embedding_dim=emb_config.dimensions or cloud.dimensions,
|
|
141
|
+
)
|
|
142
|
+
return _GraphitiOpenAIEmbedder(config=embedder_config)
|
|
143
|
+
|
|
144
|
+
else:
|
|
145
|
+
# fastembed / sentence-transformers / cohere / auto: reuse the
|
|
146
|
+
# docugraph embedder — no embedding service required.
|
|
147
|
+
return GraphitiEmbedderAdapter()
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class GraphStore:
|
|
151
|
+
"""Knowledge graph store backed by Graphiti with Kuzu driver.
|
|
152
|
+
|
|
153
|
+
Provides entity and relationship storage with temporal tracking,
|
|
154
|
+
semantic search, and graph traversal capabilities.
|
|
155
|
+
|
|
156
|
+
LLM-agnostic: Works with Ollama (local), OpenAI, or Anthropic.
|
|
157
|
+
"""
|
|
158
|
+
|
|
159
|
+
def __init__(self, db_path: Path | str | None = None) -> None:
|
|
160
|
+
"""Initialize the graph store.
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
db_path: Path to the Kuzu database directory. If None, uses config default.
|
|
164
|
+
"""
|
|
165
|
+
if db_path is None:
|
|
166
|
+
config = get_config()
|
|
167
|
+
db_path = config.storage.data_dir / "graph"
|
|
168
|
+
|
|
169
|
+
self._db_path = Path(db_path)
|
|
170
|
+
self._db_path.mkdir(parents=True, exist_ok=True)
|
|
171
|
+
|
|
172
|
+
self._graphiti: Any | None = None
|
|
173
|
+
self._driver: Any | None = None
|
|
174
|
+
self._initialized = False
|
|
175
|
+
|
|
176
|
+
async def _ensure_initialized(self) -> Any:
|
|
177
|
+
"""Lazily initialize Graphiti; returns the ready Graphiti instance."""
|
|
178
|
+
if self._initialized:
|
|
179
|
+
return self._graphiti
|
|
180
|
+
|
|
181
|
+
try:
|
|
182
|
+
from graphiti_core import Graphiti
|
|
183
|
+
from graphiti_core.driver.kuzu_driver import KuzuDriver
|
|
184
|
+
except ImportError as e:
|
|
185
|
+
raise ImportError(
|
|
186
|
+
"graphiti-core[kuzu] is required for GraphStore. "
|
|
187
|
+
"Install with: pip install 'graphiti-core[kuzu]'"
|
|
188
|
+
) from e
|
|
189
|
+
|
|
190
|
+
# Resolve the LLM and embedder before opening the database.
|
|
191
|
+
# get_llm_provider checks that a provider is actually reachable and has
|
|
192
|
+
# the model, so a failure here costs nothing on disk: no Kùzu database
|
|
193
|
+
# file, no indices, no driver handle left open. (The `graph/` directory
|
|
194
|
+
# itself is created by __init__, before this runs.)
|
|
195
|
+
llm_client = get_graphiti_llm_client()
|
|
196
|
+
embedder = _get_embedder()
|
|
197
|
+
|
|
198
|
+
# Create Kuzu driver
|
|
199
|
+
kuzu_db_path = str(self._db_path / "graphiti.kuzu")
|
|
200
|
+
self._driver = KuzuDriver(db=kuzu_db_path)
|
|
201
|
+
# graphiti-core 0.30's deprecated Kuzu backend never sets `_database`,
|
|
202
|
+
# but Graphiti._resolve_request_scope reads it for any non-default
|
|
203
|
+
# group_id (AttributeError otherwise). Kuzu's clone() is a no-op, so
|
|
204
|
+
# seeding it with the Kuzu default group id is a safe workaround.
|
|
205
|
+
if not hasattr(self._driver, "_database"):
|
|
206
|
+
self._driver._database = ""
|
|
207
|
+
|
|
208
|
+
# Initialize Graphiti with custom clients. cross_encoder is set to a
|
|
209
|
+
# no-op so ingestion doesn't require an OpenAI key (Graphiti's default
|
|
210
|
+
# reranker) or sentence-transformers/torch (its BGE reranker).
|
|
211
|
+
self._graphiti = Graphiti(
|
|
212
|
+
graph_driver=self._driver,
|
|
213
|
+
llm_client=llm_client,
|
|
214
|
+
embedder=embedder,
|
|
215
|
+
cross_encoder=NoopCrossEncoder(),
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
# Build indices for optimal performance
|
|
219
|
+
try:
|
|
220
|
+
await self._graphiti.build_indices_and_constraints()
|
|
221
|
+
except Exception as e:
|
|
222
|
+
import logging
|
|
223
|
+
|
|
224
|
+
logging.debug(f"Could not build indices (may already exist): {e}")
|
|
225
|
+
|
|
226
|
+
# graphiti-core 0.30's deprecated Kuzu backend does not actually create
|
|
227
|
+
# the full-text search indexes (build_indices_and_constraints returns
|
|
228
|
+
# without error but SHOW_INDEXES is empty), so the edge-dedup search
|
|
229
|
+
# during add_episode fails with "Table RelatesToNode_ doesn't have an
|
|
230
|
+
# index with name edge_name_and_fact". Create them explicitly here.
|
|
231
|
+
await self._ensure_fts_indexes()
|
|
232
|
+
|
|
233
|
+
self._initialized = True
|
|
234
|
+
return self._graphiti
|
|
235
|
+
|
|
236
|
+
# Kuzu FTS indexes Graphiti's search path expects (table, index, columns).
|
|
237
|
+
_KUZU_FTS_INDEXES = (
|
|
238
|
+
("Episodic", "episode_content", ("content", "source", "source_description")),
|
|
239
|
+
("Entity", "node_name_and_summary", ("name", "summary")),
|
|
240
|
+
("Community", "community_name", ("name",)),
|
|
241
|
+
("RelatesToNode_", "edge_name_and_fact", ("name", "fact")),
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
async def _ensure_fts_indexes(self) -> None:
|
|
245
|
+
"""Create any missing Kuzu full-text indexes.
|
|
246
|
+
|
|
247
|
+
Checks SHOW_INDEXES first and only creates the ones that are absent, so
|
|
248
|
+
re-initializing an existing graph produces no error-log noise from
|
|
249
|
+
Graphiti's driver (which logs before raising on "already exists").
|
|
250
|
+
"""
|
|
251
|
+
import logging
|
|
252
|
+
|
|
253
|
+
driver = self._driver
|
|
254
|
+
assert driver is not None # noqa: S101 -- set by _ensure_initialized before this runs
|
|
255
|
+
|
|
256
|
+
existing: set[str] = set()
|
|
257
|
+
try:
|
|
258
|
+
records, _, _ = await driver.execute_query("CALL SHOW_INDEXES() RETURN *;")
|
|
259
|
+
for row in records or []:
|
|
260
|
+
name = row.get("index_name")
|
|
261
|
+
if name:
|
|
262
|
+
existing.add(str(name))
|
|
263
|
+
except Exception as e:
|
|
264
|
+
logging.debug(f"Could not enumerate existing indexes: {e}")
|
|
265
|
+
|
|
266
|
+
for table, index_name, columns in self._KUZU_FTS_INDEXES:
|
|
267
|
+
if index_name in existing:
|
|
268
|
+
continue
|
|
269
|
+
col_list = ", ".join(f"'{c}'" for c in columns)
|
|
270
|
+
query = f"CALL CREATE_FTS_INDEX('{table}', '{index_name}', [{col_list}]);"
|
|
271
|
+
try:
|
|
272
|
+
await driver.execute_query(query)
|
|
273
|
+
except Exception as e:
|
|
274
|
+
# "already exists" is a benign race; anything else is non-fatal
|
|
275
|
+
# (search degrades, ingestion still works).
|
|
276
|
+
if "already exists" not in str(e).lower():
|
|
277
|
+
logging.debug(f"Could not create FTS index {index_name}: {e}")
|
|
278
|
+
|
|
279
|
+
async def close(self) -> None:
|
|
280
|
+
"""Close the graph store and release resources."""
|
|
281
|
+
if self._graphiti is not None:
|
|
282
|
+
await self._graphiti.close()
|
|
283
|
+
self._graphiti = None
|
|
284
|
+
self._driver = None
|
|
285
|
+
self._initialized = False
|
|
286
|
+
|
|
287
|
+
async def add_episode(
|
|
288
|
+
self,
|
|
289
|
+
name: str,
|
|
290
|
+
content: str,
|
|
291
|
+
source_type: str = "text",
|
|
292
|
+
source_description: str = "",
|
|
293
|
+
group_id: str = "default",
|
|
294
|
+
reference_time: datetime | None = None,
|
|
295
|
+
) -> dict[str, Any]:
|
|
296
|
+
"""Add an episode to extract entities and relationships.
|
|
297
|
+
|
|
298
|
+
Graphiti automatically extracts entities and relationships from the content
|
|
299
|
+
using the configured LLM (Ollama, OpenAI, or Anthropic).
|
|
300
|
+
|
|
301
|
+
Args:
|
|
302
|
+
name: Episode identifier/name
|
|
303
|
+
content: Text content to process
|
|
304
|
+
source_type: Type of source ("text" or "json")
|
|
305
|
+
source_description: Description of the data source
|
|
306
|
+
group_id: Group ID for scoping searches
|
|
307
|
+
reference_time: When this information became valid
|
|
308
|
+
|
|
309
|
+
Returns:
|
|
310
|
+
Dict with episode info, extracted entities, and relationships
|
|
311
|
+
"""
|
|
312
|
+
graphiti = await self._ensure_initialized()
|
|
313
|
+
|
|
314
|
+
from graphiti_core.nodes import EpisodeType
|
|
315
|
+
|
|
316
|
+
episode_type = EpisodeType.json if source_type == "json" else EpisodeType.text
|
|
317
|
+
|
|
318
|
+
if reference_time is None:
|
|
319
|
+
reference_time = datetime.now(UTC)
|
|
320
|
+
|
|
321
|
+
result = await graphiti.add_episode(
|
|
322
|
+
name=name,
|
|
323
|
+
episode_body=content,
|
|
324
|
+
source=episode_type,
|
|
325
|
+
source_description=source_description,
|
|
326
|
+
reference_time=reference_time,
|
|
327
|
+
group_id=group_id,
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
return {
|
|
331
|
+
"episode_uuid": result.episode.uuid,
|
|
332
|
+
"entities_count": len(result.nodes),
|
|
333
|
+
"relationships_count": len(result.edges),
|
|
334
|
+
"entities": [
|
|
335
|
+
{"uuid": n.uuid, "name": n.name, "labels": getattr(n, "labels", [])}
|
|
336
|
+
for n in result.nodes
|
|
337
|
+
],
|
|
338
|
+
"relationships": [{"uuid": e.uuid, "fact": e.fact} for e in result.edges],
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
async def add_entity(self, entity: Entity) -> str:
|
|
342
|
+
"""Add a single entity to the graph.
|
|
343
|
+
|
|
344
|
+
Note: For bulk entity extraction, use add_episode() which leverages
|
|
345
|
+
the configured LLM for extraction. This method is for manual entity addition.
|
|
346
|
+
|
|
347
|
+
Args:
|
|
348
|
+
entity: Entity to add
|
|
349
|
+
|
|
350
|
+
Returns:
|
|
351
|
+
Entity UUID from Graphiti
|
|
352
|
+
"""
|
|
353
|
+
graphiti = await self._ensure_initialized()
|
|
354
|
+
|
|
355
|
+
# Create an episode with structured data representing the entity
|
|
356
|
+
entity_data = {
|
|
357
|
+
"entity_id": entity.id,
|
|
358
|
+
"name": entity.name,
|
|
359
|
+
"type": entity.entity_type.value,
|
|
360
|
+
"description": entity.description or "",
|
|
361
|
+
"properties": entity.properties,
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
result = await graphiti.add_episode(
|
|
365
|
+
name=f"entity_{entity.id}",
|
|
366
|
+
episode_body=json.dumps(entity_data),
|
|
367
|
+
source="json",
|
|
368
|
+
source_description=f"Manual entity: {entity.entity_type.value}",
|
|
369
|
+
reference_time=entity.valid_from,
|
|
370
|
+
group_id="entities",
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
episode_uuid: str = result.episode.uuid
|
|
374
|
+
return episode_uuid
|
|
375
|
+
|
|
376
|
+
async def add_entities(self, entities: list[Entity]) -> int:
|
|
377
|
+
"""Add multiple entities to the graph.
|
|
378
|
+
|
|
379
|
+
Args:
|
|
380
|
+
entities: List of entities to add
|
|
381
|
+
|
|
382
|
+
Returns:
|
|
383
|
+
Number of entities added
|
|
384
|
+
"""
|
|
385
|
+
count = 0
|
|
386
|
+
for entity in entities:
|
|
387
|
+
await self.add_entity(entity)
|
|
388
|
+
count += 1
|
|
389
|
+
return count
|
|
390
|
+
|
|
391
|
+
async def search(
|
|
392
|
+
self,
|
|
393
|
+
query: str,
|
|
394
|
+
group_ids: list[str] | None = None,
|
|
395
|
+
num_results: int = 10,
|
|
396
|
+
entity_types: list[EntityType] | None = None,
|
|
397
|
+
) -> list[dict[str, Any]]:
|
|
398
|
+
"""Search the knowledge graph using hybrid semantic + keyword search.
|
|
399
|
+
|
|
400
|
+
Args:
|
|
401
|
+
query: Search query
|
|
402
|
+
group_ids: Optional list of group IDs to scope the search
|
|
403
|
+
num_results: Maximum number of results
|
|
404
|
+
entity_types: Optional filter by entity types
|
|
405
|
+
|
|
406
|
+
Returns:
|
|
407
|
+
List of matching facts/relationships with metadata
|
|
408
|
+
"""
|
|
409
|
+
graphiti = await self._ensure_initialized()
|
|
410
|
+
|
|
411
|
+
search_kwargs: dict[str, Any] = {
|
|
412
|
+
"query": query,
|
|
413
|
+
"num_results": num_results,
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
if group_ids:
|
|
417
|
+
search_kwargs["group_ids"] = group_ids
|
|
418
|
+
|
|
419
|
+
# Apply entity type filter if specified
|
|
420
|
+
if entity_types:
|
|
421
|
+
from graphiti_core.search.search_filters import SearchFilters
|
|
422
|
+
|
|
423
|
+
entity_labels = [et.value for et in entity_types]
|
|
424
|
+
search_kwargs["search_filter"] = SearchFilters(entity_labels=entity_labels)
|
|
425
|
+
|
|
426
|
+
results = await graphiti.search(**search_kwargs)
|
|
427
|
+
|
|
428
|
+
return [
|
|
429
|
+
{
|
|
430
|
+
"uuid": edge.uuid,
|
|
431
|
+
"fact": edge.fact,
|
|
432
|
+
"source_node_uuid": edge.source_node_uuid,
|
|
433
|
+
"target_node_uuid": edge.target_node_uuid,
|
|
434
|
+
"valid_at": edge.valid_at.isoformat() if edge.valid_at else None,
|
|
435
|
+
"invalid_at": edge.invalid_at.isoformat() if edge.invalid_at else None,
|
|
436
|
+
"created_at": edge.created_at.isoformat() if edge.created_at else None,
|
|
437
|
+
"episodes": list(edge.episodes) if hasattr(edge, "episodes") else [],
|
|
438
|
+
}
|
|
439
|
+
for edge in results
|
|
440
|
+
]
|
|
441
|
+
|
|
442
|
+
async def search_entities(
|
|
443
|
+
self,
|
|
444
|
+
query: str,
|
|
445
|
+
group_ids: list[str] | None = None,
|
|
446
|
+
limit: int = 10,
|
|
447
|
+
) -> list[Entity]:
|
|
448
|
+
"""Search for entities matching the query.
|
|
449
|
+
|
|
450
|
+
Args:
|
|
451
|
+
query: Search query
|
|
452
|
+
group_ids: Optional group IDs to scope search
|
|
453
|
+
limit: Maximum results
|
|
454
|
+
|
|
455
|
+
Returns:
|
|
456
|
+
List of matching Entity objects
|
|
457
|
+
"""
|
|
458
|
+
# Search returns edges/facts, extract unique entities
|
|
459
|
+
results = await self.search(query, group_ids=group_ids, num_results=limit * 2)
|
|
460
|
+
|
|
461
|
+
# Convert to Entity objects (simplified - Graphiti returns edges not nodes directly)
|
|
462
|
+
entities: list[Entity] = []
|
|
463
|
+
seen_uuids: set[str] = set()
|
|
464
|
+
|
|
465
|
+
for r in results:
|
|
466
|
+
for node_uuid in [r.get("source_node_uuid"), r.get("target_node_uuid")]:
|
|
467
|
+
if node_uuid and node_uuid not in seen_uuids:
|
|
468
|
+
seen_uuids.add(node_uuid)
|
|
469
|
+
# Create a basic entity from the fact
|
|
470
|
+
entities.append(
|
|
471
|
+
Entity(
|
|
472
|
+
id=node_uuid,
|
|
473
|
+
name=r.get("fact", "")[:100], # Use fact as name placeholder
|
|
474
|
+
entity_type=EntityType.CONCEPT,
|
|
475
|
+
description=r.get("fact"),
|
|
476
|
+
)
|
|
477
|
+
)
|
|
478
|
+
if len(entities) >= limit:
|
|
479
|
+
return entities
|
|
480
|
+
|
|
481
|
+
return entities
|
|
482
|
+
|
|
483
|
+
async def get_related(
|
|
484
|
+
self,
|
|
485
|
+
entity_uuid: str,
|
|
486
|
+
relationship_types: list[RelationshipType] | None = None,
|
|
487
|
+
depth: int = 1,
|
|
488
|
+
limit: int = 20,
|
|
489
|
+
) -> list[dict[str, Any]]:
|
|
490
|
+
"""Get entities related to a given entity.
|
|
491
|
+
|
|
492
|
+
Uses Graphiti's graph distance reranking to find related entities.
|
|
493
|
+
|
|
494
|
+
Args:
|
|
495
|
+
entity_uuid: UUID of the center entity
|
|
496
|
+
relationship_types: Optional filter by relationship types
|
|
497
|
+
depth: How deep to look — widens the result pool per hop
|
|
498
|
+
limit: Maximum results
|
|
499
|
+
|
|
500
|
+
Returns:
|
|
501
|
+
List of related facts/edges
|
|
502
|
+
"""
|
|
503
|
+
graphiti = await self._ensure_initialized()
|
|
504
|
+
|
|
505
|
+
# Use center_node_uuid for graph-distance based retrieval; deeper
|
|
506
|
+
# traversals pull a proportionally wider pool of connected facts.
|
|
507
|
+
results = await graphiti.search(
|
|
508
|
+
query="*", # Wildcard to get all connected
|
|
509
|
+
center_node_uuid=entity_uuid,
|
|
510
|
+
num_results=limit * max(1, depth),
|
|
511
|
+
)
|
|
512
|
+
|
|
513
|
+
facts = [
|
|
514
|
+
{
|
|
515
|
+
"uuid": edge.uuid,
|
|
516
|
+
"fact": edge.fact,
|
|
517
|
+
"source_node_uuid": edge.source_node_uuid,
|
|
518
|
+
"target_node_uuid": edge.target_node_uuid,
|
|
519
|
+
}
|
|
520
|
+
for edge in results
|
|
521
|
+
]
|
|
522
|
+
|
|
523
|
+
# Post-filter by relationship type (edge facts carry the type as text).
|
|
524
|
+
if relationship_types:
|
|
525
|
+
wanted = {rt.value.lower().replace("_", " ") for rt in relationship_types}
|
|
526
|
+
facts = [f for f in facts if any(w in f["fact"].lower() for w in wanted)]
|
|
527
|
+
|
|
528
|
+
return facts
|
|
529
|
+
|
|
530
|
+
async def delete_episode(self, episode_uuid: str) -> bool:
|
|
531
|
+
"""Delete an episode and its associated data.
|
|
532
|
+
|
|
533
|
+
Args:
|
|
534
|
+
episode_uuid: UUID of the episode to delete
|
|
535
|
+
|
|
536
|
+
Returns:
|
|
537
|
+
True if deleted successfully
|
|
538
|
+
"""
|
|
539
|
+
await self._ensure_initialized()
|
|
540
|
+
|
|
541
|
+
# Graphiti doesn't have direct episode deletion in the public API
|
|
542
|
+
raise NotImplementedError(
|
|
543
|
+
f"Episode deletion ({episode_uuid}) not yet supported. "
|
|
544
|
+
"Use clear() to reset the entire graph."
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
async def clear(self) -> None:
|
|
548
|
+
"""Clear all data from the graph store."""
|
|
549
|
+
await self.close()
|
|
550
|
+
|
|
551
|
+
import shutil
|
|
552
|
+
|
|
553
|
+
if self._db_path.exists():
|
|
554
|
+
shutil.rmtree(self._db_path)
|
|
555
|
+
self._db_path.mkdir(parents=True, exist_ok=True)
|
|
556
|
+
|
|
557
|
+
async def count(self) -> dict[str, int]:
|
|
558
|
+
"""Get counts of entities and relationships.
|
|
559
|
+
|
|
560
|
+
Returns:
|
|
561
|
+
Dict with entity_count and relationship_count
|
|
562
|
+
"""
|
|
563
|
+
await self._ensure_initialized()
|
|
564
|
+
|
|
565
|
+
# Graphiti doesn't expose direct count methods
|
|
566
|
+
return {
|
|
567
|
+
"entity_count": -1, # Not directly available
|
|
568
|
+
"relationship_count": -1,
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
def __enter__(self) -> GraphStore:
|
|
572
|
+
"""Context manager entry."""
|
|
573
|
+
return self
|
|
574
|
+
|
|
575
|
+
def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
576
|
+
"""Context manager exit - close the store.
|
|
577
|
+
|
|
578
|
+
The synchronous form of closing. Code inside a running loop uses
|
|
579
|
+
``async with``, whose exit awaits ``close`` directly.
|
|
580
|
+
"""
|
|
581
|
+
asyncio.run(self.close())
|
|
582
|
+
|
|
583
|
+
async def __aenter__(self) -> GraphStore:
|
|
584
|
+
"""Async context manager entry."""
|
|
585
|
+
await self._ensure_initialized()
|
|
586
|
+
return self
|
|
587
|
+
|
|
588
|
+
async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
589
|
+
"""Async context manager exit."""
|
|
590
|
+
await self.close()
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def get_graph_store(db_path: Path | str | None = None) -> GraphStore:
|
|
594
|
+
"""Factory function to get a GraphStore instance.
|
|
595
|
+
|
|
596
|
+
Args:
|
|
597
|
+
db_path: Optional database path override
|
|
598
|
+
|
|
599
|
+
Returns:
|
|
600
|
+
GraphStore instance
|
|
601
|
+
"""
|
|
602
|
+
return GraphStore(db_path=db_path)
|