alma-memory 0.4.0__py3-none-any.whl → 0.5.1__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.
- alma/__init__.py +121 -45
- alma/confidence/__init__.py +1 -1
- alma/confidence/engine.py +92 -58
- alma/confidence/types.py +34 -14
- alma/config/loader.py +3 -2
- alma/consolidation/__init__.py +23 -0
- alma/consolidation/engine.py +678 -0
- alma/consolidation/prompts.py +84 -0
- alma/core.py +136 -28
- alma/domains/__init__.py +6 -6
- alma/domains/factory.py +12 -9
- alma/domains/schemas.py +17 -3
- alma/domains/types.py +8 -4
- alma/events/__init__.py +75 -0
- alma/events/emitter.py +284 -0
- alma/events/storage_mixin.py +246 -0
- alma/events/types.py +126 -0
- alma/events/webhook.py +425 -0
- alma/exceptions.py +49 -0
- alma/extraction/__init__.py +31 -0
- alma/extraction/auto_learner.py +265 -0
- alma/extraction/extractor.py +420 -0
- alma/graph/__init__.py +106 -0
- alma/graph/backends/__init__.py +32 -0
- alma/graph/backends/kuzu.py +624 -0
- alma/graph/backends/memgraph.py +432 -0
- alma/graph/backends/memory.py +236 -0
- alma/graph/backends/neo4j.py +417 -0
- alma/graph/base.py +159 -0
- alma/graph/extraction.py +198 -0
- alma/graph/store.py +860 -0
- alma/harness/__init__.py +4 -4
- alma/harness/base.py +18 -9
- alma/harness/domains.py +27 -11
- alma/initializer/__init__.py +1 -1
- alma/initializer/initializer.py +51 -43
- alma/initializer/types.py +25 -17
- alma/integration/__init__.py +9 -9
- alma/integration/claude_agents.py +32 -20
- alma/integration/helena.py +32 -22
- alma/integration/victor.py +57 -33
- alma/learning/__init__.py +27 -27
- alma/learning/forgetting.py +198 -148
- alma/learning/heuristic_extractor.py +40 -24
- alma/learning/protocols.py +65 -17
- alma/learning/validation.py +7 -2
- alma/mcp/__init__.py +4 -4
- alma/mcp/__main__.py +2 -1
- alma/mcp/resources.py +17 -16
- alma/mcp/server.py +102 -44
- alma/mcp/tools.py +180 -45
- alma/observability/__init__.py +84 -0
- alma/observability/config.py +302 -0
- alma/observability/logging.py +424 -0
- alma/observability/metrics.py +583 -0
- alma/observability/tracing.py +440 -0
- alma/progress/__init__.py +3 -3
- alma/progress/tracker.py +26 -20
- alma/progress/types.py +8 -12
- alma/py.typed +0 -0
- alma/retrieval/__init__.py +11 -11
- alma/retrieval/cache.py +20 -21
- alma/retrieval/embeddings.py +4 -4
- alma/retrieval/engine.py +179 -39
- alma/retrieval/scoring.py +73 -63
- alma/session/__init__.py +2 -2
- alma/session/manager.py +5 -5
- alma/session/types.py +5 -4
- alma/storage/__init__.py +70 -0
- alma/storage/azure_cosmos.py +414 -133
- alma/storage/base.py +215 -4
- alma/storage/chroma.py +1443 -0
- alma/storage/constants.py +103 -0
- alma/storage/file_based.py +59 -28
- alma/storage/migrations/__init__.py +21 -0
- alma/storage/migrations/base.py +321 -0
- alma/storage/migrations/runner.py +323 -0
- alma/storage/migrations/version_stores.py +337 -0
- alma/storage/migrations/versions/__init__.py +11 -0
- alma/storage/migrations/versions/v1_0_0.py +373 -0
- alma/storage/pinecone.py +1080 -0
- alma/storage/postgresql.py +1559 -0
- alma/storage/qdrant.py +1306 -0
- alma/storage/sqlite_local.py +504 -60
- alma/testing/__init__.py +46 -0
- alma/testing/factories.py +301 -0
- alma/testing/mocks.py +389 -0
- alma/types.py +62 -14
- alma_memory-0.5.1.dist-info/METADATA +939 -0
- alma_memory-0.5.1.dist-info/RECORD +93 -0
- {alma_memory-0.4.0.dist-info → alma_memory-0.5.1.dist-info}/WHEEL +1 -1
- alma_memory-0.4.0.dist-info/METADATA +0 -488
- alma_memory-0.4.0.dist-info/RECORD +0 -52
- {alma_memory-0.4.0.dist-info → alma_memory-0.5.1.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,624 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ALMA Graph Memory - Kuzu Backend.
|
|
3
|
+
|
|
4
|
+
Kuzu embedded graph database implementation of the GraphBackend interface.
|
|
5
|
+
Kuzu is an embedded graph database similar to SQLite but for graph data.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from typing import Any, Dict, List, Optional, Set
|
|
13
|
+
|
|
14
|
+
from alma.graph.base import GraphBackend
|
|
15
|
+
from alma.graph.store import Entity, Relationship
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class KuzuBackend(GraphBackend):
|
|
21
|
+
"""
|
|
22
|
+
Kuzu embedded graph database backend.
|
|
23
|
+
|
|
24
|
+
Kuzu is an embeddable property graph database management system.
|
|
25
|
+
It supports Cypher-compatible query language and requires no server.
|
|
26
|
+
|
|
27
|
+
Requires kuzu Python package: pip install kuzu
|
|
28
|
+
|
|
29
|
+
Example usage:
|
|
30
|
+
# Persistent mode (data saved to disk)
|
|
31
|
+
backend = KuzuBackend(database_path="./my_graph_db")
|
|
32
|
+
backend.add_entity(entity)
|
|
33
|
+
backend.close()
|
|
34
|
+
|
|
35
|
+
# In-memory mode (data lost when closed)
|
|
36
|
+
backend = KuzuBackend() # No path = in-memory
|
|
37
|
+
backend.add_entity(entity)
|
|
38
|
+
backend.close()
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
database_path: Optional[str] = None,
|
|
44
|
+
read_only: bool = False,
|
|
45
|
+
):
|
|
46
|
+
"""
|
|
47
|
+
Initialize Kuzu database connection.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
database_path: Path to the database directory. If None, creates
|
|
51
|
+
a temporary in-memory database.
|
|
52
|
+
read_only: If True, open the database in read-only mode.
|
|
53
|
+
"""
|
|
54
|
+
self.database_path = database_path
|
|
55
|
+
self.read_only = read_only
|
|
56
|
+
self._db = None
|
|
57
|
+
self._conn = None
|
|
58
|
+
self._schema_initialized = False
|
|
59
|
+
|
|
60
|
+
def _get_connection(self):
|
|
61
|
+
"""Lazy initialization of Kuzu database and connection."""
|
|
62
|
+
if self._conn is None:
|
|
63
|
+
try:
|
|
64
|
+
import kuzu
|
|
65
|
+
except ImportError as err:
|
|
66
|
+
raise ImportError(
|
|
67
|
+
"kuzu package required for Kuzu graph backend. "
|
|
68
|
+
"Install with: pip install kuzu"
|
|
69
|
+
) from err
|
|
70
|
+
|
|
71
|
+
# Determine database path
|
|
72
|
+
if self.database_path is None:
|
|
73
|
+
# In-memory mode: use `:memory:` for true in-memory database
|
|
74
|
+
db_path = ":memory:"
|
|
75
|
+
else:
|
|
76
|
+
db_path = self.database_path
|
|
77
|
+
# For persistent mode, ensure parent directory exists
|
|
78
|
+
# but not the database directory itself (Kuzu will create it)
|
|
79
|
+
parent_dir = os.path.dirname(db_path)
|
|
80
|
+
if parent_dir:
|
|
81
|
+
os.makedirs(parent_dir, exist_ok=True)
|
|
82
|
+
|
|
83
|
+
self._db = kuzu.Database(db_path, read_only=self.read_only)
|
|
84
|
+
self._conn = kuzu.Connection(self._db)
|
|
85
|
+
self._initialize_schema()
|
|
86
|
+
|
|
87
|
+
return self._conn
|
|
88
|
+
|
|
89
|
+
def _initialize_schema(self) -> None:
|
|
90
|
+
"""Initialize the graph schema if not already done."""
|
|
91
|
+
if self._schema_initialized:
|
|
92
|
+
return
|
|
93
|
+
|
|
94
|
+
conn = self._conn
|
|
95
|
+
|
|
96
|
+
# Check if Entity table exists
|
|
97
|
+
try:
|
|
98
|
+
conn.execute("MATCH (e:Entity) RETURN e LIMIT 1")
|
|
99
|
+
self._schema_initialized = True
|
|
100
|
+
return
|
|
101
|
+
except Exception:
|
|
102
|
+
# Table doesn't exist, create schema
|
|
103
|
+
pass
|
|
104
|
+
|
|
105
|
+
# Create Entity node table
|
|
106
|
+
conn.execute("""
|
|
107
|
+
CREATE NODE TABLE IF NOT EXISTS Entity(
|
|
108
|
+
id STRING PRIMARY KEY,
|
|
109
|
+
name STRING,
|
|
110
|
+
entity_type STRING,
|
|
111
|
+
properties STRING,
|
|
112
|
+
project_id STRING,
|
|
113
|
+
agent STRING,
|
|
114
|
+
created_at STRING
|
|
115
|
+
)
|
|
116
|
+
""")
|
|
117
|
+
|
|
118
|
+
# Create Relationship edge table
|
|
119
|
+
# In Kuzu, we need a generic edge table since relationship types are dynamic
|
|
120
|
+
conn.execute("""
|
|
121
|
+
CREATE REL TABLE IF NOT EXISTS RELATES_TO(
|
|
122
|
+
FROM Entity TO Entity,
|
|
123
|
+
id STRING,
|
|
124
|
+
relation_type STRING,
|
|
125
|
+
properties STRING,
|
|
126
|
+
confidence DOUBLE,
|
|
127
|
+
created_at STRING
|
|
128
|
+
)
|
|
129
|
+
""")
|
|
130
|
+
|
|
131
|
+
self._schema_initialized = True
|
|
132
|
+
|
|
133
|
+
def _run_query(
|
|
134
|
+
self, query: str, parameters: Optional[Dict[str, Any]] = None
|
|
135
|
+
) -> List[Dict]:
|
|
136
|
+
"""Execute a Cypher query and return results as list of dicts."""
|
|
137
|
+
conn = self._get_connection()
|
|
138
|
+
try:
|
|
139
|
+
if parameters:
|
|
140
|
+
result = conn.execute(query, parameters)
|
|
141
|
+
else:
|
|
142
|
+
result = conn.execute(query)
|
|
143
|
+
|
|
144
|
+
# Convert result to list of dicts
|
|
145
|
+
rows = []
|
|
146
|
+
while result.has_next():
|
|
147
|
+
row = result.get_next()
|
|
148
|
+
# Get column names
|
|
149
|
+
col_names = result.get_column_names()
|
|
150
|
+
row_dict = {}
|
|
151
|
+
for i, col_name in enumerate(col_names):
|
|
152
|
+
row_dict[col_name] = row[i]
|
|
153
|
+
rows.append(row_dict)
|
|
154
|
+
return rows
|
|
155
|
+
except Exception as e:
|
|
156
|
+
logger.debug(f"Query error: {e}, Query: {query}, Params: {parameters}")
|
|
157
|
+
raise
|
|
158
|
+
|
|
159
|
+
def add_entity(self, entity: Entity) -> str:
|
|
160
|
+
"""Add or update an entity in Kuzu."""
|
|
161
|
+
# Extract project_id and agent from properties if present
|
|
162
|
+
properties = entity.properties.copy()
|
|
163
|
+
project_id = properties.pop("project_id", None) or ""
|
|
164
|
+
agent = properties.pop("agent", None) or ""
|
|
165
|
+
|
|
166
|
+
# Check if entity exists
|
|
167
|
+
existing = self.get_entity(entity.id)
|
|
168
|
+
|
|
169
|
+
if existing:
|
|
170
|
+
# Update existing entity
|
|
171
|
+
query = """
|
|
172
|
+
MATCH (e:Entity {id: $id})
|
|
173
|
+
SET e.name = $name,
|
|
174
|
+
e.entity_type = $entity_type,
|
|
175
|
+
e.properties = $properties,
|
|
176
|
+
e.project_id = $project_id,
|
|
177
|
+
e.agent = $agent,
|
|
178
|
+
e.created_at = $created_at
|
|
179
|
+
"""
|
|
180
|
+
else:
|
|
181
|
+
# Create new entity
|
|
182
|
+
query = """
|
|
183
|
+
CREATE (e:Entity {
|
|
184
|
+
id: $id,
|
|
185
|
+
name: $name,
|
|
186
|
+
entity_type: $entity_type,
|
|
187
|
+
properties: $properties,
|
|
188
|
+
project_id: $project_id,
|
|
189
|
+
agent: $agent,
|
|
190
|
+
created_at: $created_at
|
|
191
|
+
})
|
|
192
|
+
"""
|
|
193
|
+
|
|
194
|
+
params = {
|
|
195
|
+
"id": entity.id,
|
|
196
|
+
"name": entity.name,
|
|
197
|
+
"entity_type": entity.entity_type,
|
|
198
|
+
"properties": json.dumps(properties),
|
|
199
|
+
"project_id": project_id,
|
|
200
|
+
"agent": agent,
|
|
201
|
+
"created_at": entity.created_at.isoformat(),
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
self._run_query(query, params)
|
|
205
|
+
return entity.id
|
|
206
|
+
|
|
207
|
+
def add_relationship(self, relationship: Relationship) -> str:
|
|
208
|
+
"""Add or update a relationship in Kuzu."""
|
|
209
|
+
# Check if relationship exists
|
|
210
|
+
check_query = """
|
|
211
|
+
MATCH (s:Entity)-[r:RELATES_TO]->(t:Entity)
|
|
212
|
+
WHERE r.id = $id
|
|
213
|
+
RETURN r.id
|
|
214
|
+
"""
|
|
215
|
+
existing = self._run_query(check_query, {"id": relationship.id})
|
|
216
|
+
|
|
217
|
+
if existing:
|
|
218
|
+
# Update existing relationship
|
|
219
|
+
query = """
|
|
220
|
+
MATCH (s:Entity)-[r:RELATES_TO]->(t:Entity)
|
|
221
|
+
WHERE r.id = $id
|
|
222
|
+
SET r.relation_type = $relation_type,
|
|
223
|
+
r.properties = $properties,
|
|
224
|
+
r.confidence = $confidence,
|
|
225
|
+
r.created_at = $created_at
|
|
226
|
+
"""
|
|
227
|
+
params = {
|
|
228
|
+
"id": relationship.id,
|
|
229
|
+
"relation_type": relationship.relation_type,
|
|
230
|
+
"properties": json.dumps(relationship.properties),
|
|
231
|
+
"confidence": relationship.confidence,
|
|
232
|
+
"created_at": relationship.created_at.isoformat(),
|
|
233
|
+
}
|
|
234
|
+
else:
|
|
235
|
+
# Create new relationship
|
|
236
|
+
query = """
|
|
237
|
+
MATCH (s:Entity {id: $source_id}), (t:Entity {id: $target_id})
|
|
238
|
+
CREATE (s)-[r:RELATES_TO {
|
|
239
|
+
id: $id,
|
|
240
|
+
relation_type: $relation_type,
|
|
241
|
+
properties: $properties,
|
|
242
|
+
confidence: $confidence,
|
|
243
|
+
created_at: $created_at
|
|
244
|
+
}]->(t)
|
|
245
|
+
"""
|
|
246
|
+
params = {
|
|
247
|
+
"id": relationship.id,
|
|
248
|
+
"source_id": relationship.source_id,
|
|
249
|
+
"target_id": relationship.target_id,
|
|
250
|
+
"relation_type": relationship.relation_type,
|
|
251
|
+
"properties": json.dumps(relationship.properties),
|
|
252
|
+
"confidence": relationship.confidence,
|
|
253
|
+
"created_at": relationship.created_at.isoformat(),
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
self._run_query(query, params)
|
|
257
|
+
return relationship.id
|
|
258
|
+
|
|
259
|
+
def get_entity(self, entity_id: str) -> Optional[Entity]:
|
|
260
|
+
"""Get an entity by ID."""
|
|
261
|
+
query = """
|
|
262
|
+
MATCH (e:Entity {id: $id})
|
|
263
|
+
RETURN e.id AS id, e.name AS name, e.entity_type AS entity_type,
|
|
264
|
+
e.properties AS properties, e.created_at AS created_at,
|
|
265
|
+
e.project_id AS project_id, e.agent AS agent
|
|
266
|
+
"""
|
|
267
|
+
results = self._run_query(query, {"id": entity_id})
|
|
268
|
+
|
|
269
|
+
if not results:
|
|
270
|
+
return None
|
|
271
|
+
|
|
272
|
+
r = results[0]
|
|
273
|
+
properties = json.loads(r["properties"]) if r["properties"] else {}
|
|
274
|
+
|
|
275
|
+
# Add project_id and agent back to properties if present
|
|
276
|
+
if r.get("project_id"):
|
|
277
|
+
properties["project_id"] = r["project_id"]
|
|
278
|
+
if r.get("agent"):
|
|
279
|
+
properties["agent"] = r["agent"]
|
|
280
|
+
|
|
281
|
+
return Entity(
|
|
282
|
+
id=r["id"],
|
|
283
|
+
name=r["name"],
|
|
284
|
+
entity_type=r["entity_type"],
|
|
285
|
+
properties=properties,
|
|
286
|
+
created_at=(
|
|
287
|
+
datetime.fromisoformat(r["created_at"])
|
|
288
|
+
if r["created_at"]
|
|
289
|
+
else datetime.now(timezone.utc)
|
|
290
|
+
),
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
def get_entities(
|
|
294
|
+
self,
|
|
295
|
+
entity_type: Optional[str] = None,
|
|
296
|
+
project_id: Optional[str] = None,
|
|
297
|
+
agent: Optional[str] = None,
|
|
298
|
+
limit: int = 100,
|
|
299
|
+
) -> List[Entity]:
|
|
300
|
+
"""Get entities with optional filtering."""
|
|
301
|
+
conditions = []
|
|
302
|
+
params: Dict[str, Any] = {"limit": limit}
|
|
303
|
+
|
|
304
|
+
if entity_type:
|
|
305
|
+
conditions.append("e.entity_type = $entity_type")
|
|
306
|
+
params["entity_type"] = entity_type
|
|
307
|
+
if project_id:
|
|
308
|
+
conditions.append("e.project_id = $project_id")
|
|
309
|
+
params["project_id"] = project_id
|
|
310
|
+
if agent:
|
|
311
|
+
conditions.append("e.agent = $agent")
|
|
312
|
+
params["agent"] = agent
|
|
313
|
+
|
|
314
|
+
where_clause = f"WHERE {' AND '.join(conditions)}" if conditions else ""
|
|
315
|
+
|
|
316
|
+
query = f"""
|
|
317
|
+
MATCH (e:Entity)
|
|
318
|
+
{where_clause}
|
|
319
|
+
RETURN e.id AS id, e.name AS name, e.entity_type AS entity_type,
|
|
320
|
+
e.properties AS properties, e.created_at AS created_at,
|
|
321
|
+
e.project_id AS project_id, e.agent AS agent
|
|
322
|
+
LIMIT $limit
|
|
323
|
+
"""
|
|
324
|
+
|
|
325
|
+
results = self._run_query(query, params)
|
|
326
|
+
entities = []
|
|
327
|
+
|
|
328
|
+
for r in results:
|
|
329
|
+
properties = json.loads(r["properties"]) if r["properties"] else {}
|
|
330
|
+
if r.get("project_id"):
|
|
331
|
+
properties["project_id"] = r["project_id"]
|
|
332
|
+
if r.get("agent"):
|
|
333
|
+
properties["agent"] = r["agent"]
|
|
334
|
+
|
|
335
|
+
entities.append(
|
|
336
|
+
Entity(
|
|
337
|
+
id=r["id"],
|
|
338
|
+
name=r["name"],
|
|
339
|
+
entity_type=r["entity_type"],
|
|
340
|
+
properties=properties,
|
|
341
|
+
created_at=(
|
|
342
|
+
datetime.fromisoformat(r["created_at"])
|
|
343
|
+
if r["created_at"]
|
|
344
|
+
else datetime.now(timezone.utc)
|
|
345
|
+
),
|
|
346
|
+
)
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
return entities
|
|
350
|
+
|
|
351
|
+
def get_relationships(self, entity_id: str) -> List[Relationship]:
|
|
352
|
+
"""Get all relationships for an entity (both directions)."""
|
|
353
|
+
# Get outgoing relationships
|
|
354
|
+
outgoing_query = """
|
|
355
|
+
MATCH (s:Entity {id: $entity_id})-[r:RELATES_TO]->(t:Entity)
|
|
356
|
+
RETURN r.id AS id, s.id AS source_id, t.id AS target_id,
|
|
357
|
+
r.relation_type AS relation_type, r.properties AS properties,
|
|
358
|
+
r.confidence AS confidence, r.created_at AS created_at
|
|
359
|
+
"""
|
|
360
|
+
|
|
361
|
+
# Get incoming relationships
|
|
362
|
+
incoming_query = """
|
|
363
|
+
MATCH (s:Entity)-[r:RELATES_TO]->(t:Entity {id: $entity_id})
|
|
364
|
+
RETURN r.id AS id, s.id AS source_id, t.id AS target_id,
|
|
365
|
+
r.relation_type AS relation_type, r.properties AS properties,
|
|
366
|
+
r.confidence AS confidence, r.created_at AS created_at
|
|
367
|
+
"""
|
|
368
|
+
|
|
369
|
+
params = {"entity_id": entity_id}
|
|
370
|
+
outgoing = self._run_query(outgoing_query, params)
|
|
371
|
+
incoming = self._run_query(incoming_query, params)
|
|
372
|
+
|
|
373
|
+
# Deduplicate by relationship ID
|
|
374
|
+
seen_ids: Set[str] = set()
|
|
375
|
+
relationships = []
|
|
376
|
+
|
|
377
|
+
for r in outgoing + incoming:
|
|
378
|
+
rel_id = (
|
|
379
|
+
r["id"] or f"{r['source_id']}-{r['relation_type']}-{r['target_id']}"
|
|
380
|
+
)
|
|
381
|
+
if rel_id in seen_ids:
|
|
382
|
+
continue
|
|
383
|
+
seen_ids.add(rel_id)
|
|
384
|
+
|
|
385
|
+
relationships.append(
|
|
386
|
+
Relationship(
|
|
387
|
+
id=rel_id,
|
|
388
|
+
source_id=r["source_id"],
|
|
389
|
+
target_id=r["target_id"],
|
|
390
|
+
relation_type=r["relation_type"] or "RELATES_TO",
|
|
391
|
+
properties=json.loads(r["properties"]) if r["properties"] else {},
|
|
392
|
+
confidence=r["confidence"] if r["confidence"] is not None else 1.0,
|
|
393
|
+
created_at=(
|
|
394
|
+
datetime.fromisoformat(r["created_at"])
|
|
395
|
+
if r["created_at"]
|
|
396
|
+
else datetime.now(timezone.utc)
|
|
397
|
+
),
|
|
398
|
+
)
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
return relationships
|
|
402
|
+
|
|
403
|
+
def search_entities(
|
|
404
|
+
self,
|
|
405
|
+
query: str,
|
|
406
|
+
embedding: Optional[List[float]] = None,
|
|
407
|
+
top_k: int = 10,
|
|
408
|
+
) -> List[Entity]:
|
|
409
|
+
"""
|
|
410
|
+
Search for entities by name.
|
|
411
|
+
|
|
412
|
+
Note: Vector similarity search is not implemented for Kuzu backend.
|
|
413
|
+
Falls back to case-insensitive text search.
|
|
414
|
+
"""
|
|
415
|
+
# Kuzu uses CONTAINS for substring matching
|
|
416
|
+
cypher = """
|
|
417
|
+
MATCH (e:Entity)
|
|
418
|
+
WHERE lower(e.name) CONTAINS lower($query)
|
|
419
|
+
RETURN e.id AS id, e.name AS name, e.entity_type AS entity_type,
|
|
420
|
+
e.properties AS properties, e.created_at AS created_at,
|
|
421
|
+
e.project_id AS project_id, e.agent AS agent
|
|
422
|
+
LIMIT $limit
|
|
423
|
+
"""
|
|
424
|
+
|
|
425
|
+
results = self._run_query(cypher, {"query": query, "limit": top_k})
|
|
426
|
+
entities = []
|
|
427
|
+
|
|
428
|
+
for r in results:
|
|
429
|
+
properties = json.loads(r["properties"]) if r["properties"] else {}
|
|
430
|
+
if r.get("project_id"):
|
|
431
|
+
properties["project_id"] = r["project_id"]
|
|
432
|
+
if r.get("agent"):
|
|
433
|
+
properties["agent"] = r["agent"]
|
|
434
|
+
|
|
435
|
+
entities.append(
|
|
436
|
+
Entity(
|
|
437
|
+
id=r["id"],
|
|
438
|
+
name=r["name"],
|
|
439
|
+
entity_type=r["entity_type"],
|
|
440
|
+
properties=properties,
|
|
441
|
+
created_at=(
|
|
442
|
+
datetime.fromisoformat(r["created_at"])
|
|
443
|
+
if r["created_at"]
|
|
444
|
+
else datetime.now(timezone.utc)
|
|
445
|
+
),
|
|
446
|
+
)
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
return entities
|
|
450
|
+
|
|
451
|
+
def delete_entity(self, entity_id: str) -> bool:
|
|
452
|
+
"""Delete an entity and all its relationships."""
|
|
453
|
+
# Check if entity exists
|
|
454
|
+
entity = self.get_entity(entity_id)
|
|
455
|
+
if not entity:
|
|
456
|
+
return False
|
|
457
|
+
|
|
458
|
+
# Delete relationships first (both directions)
|
|
459
|
+
self._run_query(
|
|
460
|
+
"""
|
|
461
|
+
MATCH (s:Entity {id: $id})-[r:RELATES_TO]->()
|
|
462
|
+
DELETE r
|
|
463
|
+
""",
|
|
464
|
+
{"id": entity_id},
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
self._run_query(
|
|
468
|
+
"""
|
|
469
|
+
MATCH ()-[r:RELATES_TO]->(t:Entity {id: $id})
|
|
470
|
+
DELETE r
|
|
471
|
+
""",
|
|
472
|
+
{"id": entity_id},
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
# Delete the entity
|
|
476
|
+
self._run_query(
|
|
477
|
+
"""
|
|
478
|
+
MATCH (e:Entity {id: $id})
|
|
479
|
+
DELETE e
|
|
480
|
+
""",
|
|
481
|
+
{"id": entity_id},
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
return True
|
|
485
|
+
|
|
486
|
+
def delete_relationship(self, relationship_id: str) -> bool:
|
|
487
|
+
"""Delete a specific relationship by ID."""
|
|
488
|
+
# Check if relationship exists
|
|
489
|
+
check_query = """
|
|
490
|
+
MATCH ()-[r:RELATES_TO]->()
|
|
491
|
+
WHERE r.id = $id
|
|
492
|
+
RETURN r.id
|
|
493
|
+
"""
|
|
494
|
+
existing = self._run_query(check_query, {"id": relationship_id})
|
|
495
|
+
|
|
496
|
+
if not existing:
|
|
497
|
+
return False
|
|
498
|
+
|
|
499
|
+
# Delete the relationship
|
|
500
|
+
self._run_query(
|
|
501
|
+
"""
|
|
502
|
+
MATCH ()-[r:RELATES_TO]->()
|
|
503
|
+
WHERE r.id = $id
|
|
504
|
+
DELETE r
|
|
505
|
+
""",
|
|
506
|
+
{"id": relationship_id},
|
|
507
|
+
)
|
|
508
|
+
|
|
509
|
+
return True
|
|
510
|
+
|
|
511
|
+
def close(self) -> None:
|
|
512
|
+
"""Close the Kuzu database connection and clean up resources."""
|
|
513
|
+
if self._conn is not None:
|
|
514
|
+
self._conn = None
|
|
515
|
+
|
|
516
|
+
if self._db is not None:
|
|
517
|
+
self._db = None
|
|
518
|
+
|
|
519
|
+
self._schema_initialized = False
|
|
520
|
+
|
|
521
|
+
def clear(self) -> None:
|
|
522
|
+
"""Clear all data from the database."""
|
|
523
|
+
conn = self._get_connection()
|
|
524
|
+
|
|
525
|
+
# Delete all relationships first
|
|
526
|
+
conn.execute("MATCH ()-[r:RELATES_TO]->() DELETE r")
|
|
527
|
+
|
|
528
|
+
# Delete all entities
|
|
529
|
+
conn.execute("MATCH (e:Entity) DELETE e")
|
|
530
|
+
|
|
531
|
+
# Additional methods for compatibility with existing GraphStore API
|
|
532
|
+
|
|
533
|
+
def find_entities(
|
|
534
|
+
self,
|
|
535
|
+
name: Optional[str] = None,
|
|
536
|
+
entity_type: Optional[str] = None,
|
|
537
|
+
limit: int = 10,
|
|
538
|
+
) -> List[Entity]:
|
|
539
|
+
"""
|
|
540
|
+
Find entities by name or type.
|
|
541
|
+
|
|
542
|
+
This method provides compatibility with the existing GraphStore API.
|
|
543
|
+
"""
|
|
544
|
+
if name:
|
|
545
|
+
return self.search_entities(query=name, top_k=limit)
|
|
546
|
+
|
|
547
|
+
return self.get_entities(entity_type=entity_type, limit=limit)
|
|
548
|
+
|
|
549
|
+
def get_relationships_directional(
|
|
550
|
+
self,
|
|
551
|
+
entity_id: str,
|
|
552
|
+
direction: str = "both",
|
|
553
|
+
relation_type: Optional[str] = None,
|
|
554
|
+
) -> List[Relationship]:
|
|
555
|
+
"""
|
|
556
|
+
Get relationships for an entity with direction control.
|
|
557
|
+
|
|
558
|
+
This method provides compatibility with the existing GraphStore API.
|
|
559
|
+
|
|
560
|
+
Args:
|
|
561
|
+
entity_id: The entity ID.
|
|
562
|
+
direction: "outgoing", "incoming", or "both".
|
|
563
|
+
relation_type: Optional filter by relationship type.
|
|
564
|
+
|
|
565
|
+
Returns:
|
|
566
|
+
List of matching relationships.
|
|
567
|
+
"""
|
|
568
|
+
results = []
|
|
569
|
+
params: Dict[str, Any] = {"entity_id": entity_id}
|
|
570
|
+
|
|
571
|
+
type_filter = ""
|
|
572
|
+
if relation_type:
|
|
573
|
+
type_filter = "AND r.relation_type = $relation_type"
|
|
574
|
+
params["relation_type"] = relation_type
|
|
575
|
+
|
|
576
|
+
if direction in ("outgoing", "both"):
|
|
577
|
+
outgoing_query = f"""
|
|
578
|
+
MATCH (s:Entity {{id: $entity_id}})-[r:RELATES_TO]->(t:Entity)
|
|
579
|
+
WHERE true {type_filter}
|
|
580
|
+
RETURN r.id AS id, s.id AS source_id, t.id AS target_id,
|
|
581
|
+
r.relation_type AS relation_type, r.properties AS properties,
|
|
582
|
+
r.confidence AS confidence, r.created_at AS created_at
|
|
583
|
+
"""
|
|
584
|
+
results.extend(self._run_query(outgoing_query, params))
|
|
585
|
+
|
|
586
|
+
if direction in ("incoming", "both"):
|
|
587
|
+
incoming_query = f"""
|
|
588
|
+
MATCH (s:Entity)-[r:RELATES_TO]->(t:Entity {{id: $entity_id}})
|
|
589
|
+
WHERE true {type_filter}
|
|
590
|
+
RETURN r.id AS id, s.id AS source_id, t.id AS target_id,
|
|
591
|
+
r.relation_type AS relation_type, r.properties AS properties,
|
|
592
|
+
r.confidence AS confidence, r.created_at AS created_at
|
|
593
|
+
"""
|
|
594
|
+
results.extend(self._run_query(incoming_query, params))
|
|
595
|
+
|
|
596
|
+
# Deduplicate by relationship ID
|
|
597
|
+
seen_ids: Set[str] = set()
|
|
598
|
+
relationships = []
|
|
599
|
+
|
|
600
|
+
for r in results:
|
|
601
|
+
rel_id = (
|
|
602
|
+
r["id"] or f"{r['source_id']}-{r['relation_type']}-{r['target_id']}"
|
|
603
|
+
)
|
|
604
|
+
if rel_id in seen_ids:
|
|
605
|
+
continue
|
|
606
|
+
seen_ids.add(rel_id)
|
|
607
|
+
|
|
608
|
+
relationships.append(
|
|
609
|
+
Relationship(
|
|
610
|
+
id=rel_id,
|
|
611
|
+
source_id=r["source_id"],
|
|
612
|
+
target_id=r["target_id"],
|
|
613
|
+
relation_type=r["relation_type"] or "RELATES_TO",
|
|
614
|
+
properties=json.loads(r["properties"]) if r["properties"] else {},
|
|
615
|
+
confidence=r["confidence"] if r["confidence"] is not None else 1.0,
|
|
616
|
+
created_at=(
|
|
617
|
+
datetime.fromisoformat(r["created_at"])
|
|
618
|
+
if r["created_at"]
|
|
619
|
+
else datetime.now(timezone.utc)
|
|
620
|
+
),
|
|
621
|
+
)
|
|
622
|
+
)
|
|
623
|
+
|
|
624
|
+
return relationships
|