alma-memory 0.5.0__py3-none-any.whl → 0.7.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.
Files changed (111) hide show
  1. alma/__init__.py +296 -194
  2. alma/compression/__init__.py +33 -0
  3. alma/compression/pipeline.py +980 -0
  4. alma/confidence/__init__.py +47 -47
  5. alma/confidence/engine.py +540 -540
  6. alma/confidence/types.py +351 -351
  7. alma/config/loader.py +157 -157
  8. alma/consolidation/__init__.py +23 -23
  9. alma/consolidation/engine.py +678 -678
  10. alma/consolidation/prompts.py +84 -84
  11. alma/core.py +1189 -322
  12. alma/domains/__init__.py +30 -30
  13. alma/domains/factory.py +359 -359
  14. alma/domains/schemas.py +448 -448
  15. alma/domains/types.py +272 -272
  16. alma/events/__init__.py +75 -75
  17. alma/events/emitter.py +285 -284
  18. alma/events/storage_mixin.py +246 -246
  19. alma/events/types.py +126 -126
  20. alma/events/webhook.py +425 -425
  21. alma/exceptions.py +49 -49
  22. alma/extraction/__init__.py +31 -31
  23. alma/extraction/auto_learner.py +265 -264
  24. alma/extraction/extractor.py +420 -420
  25. alma/graph/__init__.py +106 -81
  26. alma/graph/backends/__init__.py +32 -18
  27. alma/graph/backends/kuzu.py +624 -0
  28. alma/graph/backends/memgraph.py +432 -0
  29. alma/graph/backends/memory.py +236 -236
  30. alma/graph/backends/neo4j.py +417 -417
  31. alma/graph/base.py +159 -159
  32. alma/graph/extraction.py +198 -198
  33. alma/graph/store.py +860 -860
  34. alma/harness/__init__.py +35 -35
  35. alma/harness/base.py +386 -386
  36. alma/harness/domains.py +705 -705
  37. alma/initializer/__init__.py +37 -37
  38. alma/initializer/initializer.py +418 -418
  39. alma/initializer/types.py +250 -250
  40. alma/integration/__init__.py +62 -62
  41. alma/integration/claude_agents.py +444 -432
  42. alma/integration/helena.py +423 -423
  43. alma/integration/victor.py +471 -471
  44. alma/learning/__init__.py +101 -86
  45. alma/learning/decay.py +878 -0
  46. alma/learning/forgetting.py +1446 -1446
  47. alma/learning/heuristic_extractor.py +390 -390
  48. alma/learning/protocols.py +374 -374
  49. alma/learning/validation.py +346 -346
  50. alma/mcp/__init__.py +123 -45
  51. alma/mcp/__main__.py +156 -156
  52. alma/mcp/resources.py +122 -122
  53. alma/mcp/server.py +955 -591
  54. alma/mcp/tools.py +3254 -511
  55. alma/observability/__init__.py +91 -0
  56. alma/observability/config.py +302 -0
  57. alma/observability/guidelines.py +170 -0
  58. alma/observability/logging.py +424 -0
  59. alma/observability/metrics.py +583 -0
  60. alma/observability/tracing.py +440 -0
  61. alma/progress/__init__.py +21 -21
  62. alma/progress/tracker.py +607 -607
  63. alma/progress/types.py +250 -250
  64. alma/retrieval/__init__.py +134 -53
  65. alma/retrieval/budget.py +525 -0
  66. alma/retrieval/cache.py +1304 -1061
  67. alma/retrieval/embeddings.py +202 -202
  68. alma/retrieval/engine.py +850 -366
  69. alma/retrieval/modes.py +365 -0
  70. alma/retrieval/progressive.py +560 -0
  71. alma/retrieval/scoring.py +344 -344
  72. alma/retrieval/trust_scoring.py +637 -0
  73. alma/retrieval/verification.py +797 -0
  74. alma/session/__init__.py +19 -19
  75. alma/session/manager.py +442 -399
  76. alma/session/types.py +288 -288
  77. alma/storage/__init__.py +101 -61
  78. alma/storage/archive.py +233 -0
  79. alma/storage/azure_cosmos.py +1259 -1048
  80. alma/storage/base.py +1083 -525
  81. alma/storage/chroma.py +1443 -1443
  82. alma/storage/constants.py +103 -0
  83. alma/storage/file_based.py +614 -619
  84. alma/storage/migrations/__init__.py +21 -0
  85. alma/storage/migrations/base.py +321 -0
  86. alma/storage/migrations/runner.py +323 -0
  87. alma/storage/migrations/version_stores.py +337 -0
  88. alma/storage/migrations/versions/__init__.py +11 -0
  89. alma/storage/migrations/versions/v1_0_0.py +373 -0
  90. alma/storage/migrations/versions/v1_1_0_workflow_context.py +551 -0
  91. alma/storage/pinecone.py +1080 -1080
  92. alma/storage/postgresql.py +1948 -1452
  93. alma/storage/qdrant.py +1306 -1306
  94. alma/storage/sqlite_local.py +3041 -1358
  95. alma/testing/__init__.py +46 -0
  96. alma/testing/factories.py +301 -0
  97. alma/testing/mocks.py +389 -0
  98. alma/types.py +292 -264
  99. alma/utils/__init__.py +19 -0
  100. alma/utils/tokenizer.py +521 -0
  101. alma/workflow/__init__.py +83 -0
  102. alma/workflow/artifacts.py +170 -0
  103. alma/workflow/checkpoint.py +311 -0
  104. alma/workflow/context.py +228 -0
  105. alma/workflow/outcomes.py +189 -0
  106. alma/workflow/reducers.py +393 -0
  107. {alma_memory-0.5.0.dist-info → alma_memory-0.7.0.dist-info}/METADATA +244 -72
  108. alma_memory-0.7.0.dist-info/RECORD +112 -0
  109. alma_memory-0.5.0.dist-info/RECORD +0 -76
  110. {alma_memory-0.5.0.dist-info → alma_memory-0.7.0.dist-info}/WHEEL +0 -0
  111. {alma_memory-0.5.0.dist-info → alma_memory-0.7.0.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