alma-memory 0.5.1__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.
- alma/__init__.py +296 -226
- alma/compression/__init__.py +33 -0
- alma/compression/pipeline.py +980 -0
- alma/confidence/__init__.py +47 -47
- alma/confidence/engine.py +540 -540
- alma/confidence/types.py +351 -351
- alma/config/loader.py +157 -157
- alma/consolidation/__init__.py +23 -23
- alma/consolidation/engine.py +678 -678
- alma/consolidation/prompts.py +84 -84
- alma/core.py +1189 -430
- alma/domains/__init__.py +30 -30
- alma/domains/factory.py +359 -359
- alma/domains/schemas.py +448 -448
- alma/domains/types.py +272 -272
- alma/events/__init__.py +75 -75
- alma/events/emitter.py +285 -284
- alma/events/storage_mixin.py +246 -246
- alma/events/types.py +126 -126
- alma/events/webhook.py +425 -425
- alma/exceptions.py +49 -49
- alma/extraction/__init__.py +31 -31
- alma/extraction/auto_learner.py +265 -265
- alma/extraction/extractor.py +420 -420
- alma/graph/__init__.py +106 -106
- alma/graph/backends/__init__.py +32 -32
- alma/graph/backends/kuzu.py +624 -624
- alma/graph/backends/memgraph.py +432 -432
- alma/graph/backends/memory.py +236 -236
- alma/graph/backends/neo4j.py +417 -417
- alma/graph/base.py +159 -159
- alma/graph/extraction.py +198 -198
- alma/graph/store.py +860 -860
- alma/harness/__init__.py +35 -35
- alma/harness/base.py +386 -386
- alma/harness/domains.py +705 -705
- alma/initializer/__init__.py +37 -37
- alma/initializer/initializer.py +418 -418
- alma/initializer/types.py +250 -250
- alma/integration/__init__.py +62 -62
- alma/integration/claude_agents.py +444 -444
- alma/integration/helena.py +423 -423
- alma/integration/victor.py +471 -471
- alma/learning/__init__.py +101 -86
- alma/learning/decay.py +878 -0
- alma/learning/forgetting.py +1446 -1446
- alma/learning/heuristic_extractor.py +390 -390
- alma/learning/protocols.py +374 -374
- alma/learning/validation.py +346 -346
- alma/mcp/__init__.py +123 -45
- alma/mcp/__main__.py +156 -156
- alma/mcp/resources.py +122 -122
- alma/mcp/server.py +955 -591
- alma/mcp/tools.py +3254 -509
- alma/observability/__init__.py +91 -84
- alma/observability/config.py +302 -302
- alma/observability/guidelines.py +170 -0
- alma/observability/logging.py +424 -424
- alma/observability/metrics.py +583 -583
- alma/observability/tracing.py +440 -440
- alma/progress/__init__.py +21 -21
- alma/progress/tracker.py +607 -607
- alma/progress/types.py +250 -250
- alma/retrieval/__init__.py +134 -53
- alma/retrieval/budget.py +525 -0
- alma/retrieval/cache.py +1304 -1061
- alma/retrieval/embeddings.py +202 -202
- alma/retrieval/engine.py +850 -427
- alma/retrieval/modes.py +365 -0
- alma/retrieval/progressive.py +560 -0
- alma/retrieval/scoring.py +344 -344
- alma/retrieval/trust_scoring.py +637 -0
- alma/retrieval/verification.py +797 -0
- alma/session/__init__.py +19 -19
- alma/session/manager.py +442 -399
- alma/session/types.py +288 -288
- alma/storage/__init__.py +101 -90
- alma/storage/archive.py +233 -0
- alma/storage/azure_cosmos.py +1259 -1259
- alma/storage/base.py +1083 -583
- alma/storage/chroma.py +1443 -1443
- alma/storage/constants.py +103 -103
- alma/storage/file_based.py +614 -614
- alma/storage/migrations/__init__.py +21 -21
- alma/storage/migrations/base.py +321 -321
- alma/storage/migrations/runner.py +323 -323
- alma/storage/migrations/version_stores.py +337 -337
- alma/storage/migrations/versions/__init__.py +11 -11
- alma/storage/migrations/versions/v1_0_0.py +373 -373
- alma/storage/migrations/versions/v1_1_0_workflow_context.py +551 -0
- alma/storage/pinecone.py +1080 -1080
- alma/storage/postgresql.py +1948 -1559
- alma/storage/qdrant.py +1306 -1306
- alma/storage/sqlite_local.py +3041 -1457
- alma/testing/__init__.py +46 -46
- alma/testing/factories.py +301 -301
- alma/testing/mocks.py +389 -389
- alma/types.py +292 -264
- alma/utils/__init__.py +19 -0
- alma/utils/tokenizer.py +521 -0
- alma/workflow/__init__.py +83 -0
- alma/workflow/artifacts.py +170 -0
- alma/workflow/checkpoint.py +311 -0
- alma/workflow/context.py +228 -0
- alma/workflow/outcomes.py +189 -0
- alma/workflow/reducers.py +393 -0
- {alma_memory-0.5.1.dist-info → alma_memory-0.7.0.dist-info}/METADATA +210 -72
- alma_memory-0.7.0.dist-info/RECORD +112 -0
- alma_memory-0.5.1.dist-info/RECORD +0 -93
- {alma_memory-0.5.1.dist-info → alma_memory-0.7.0.dist-info}/WHEEL +0 -0
- {alma_memory-0.5.1.dist-info → alma_memory-0.7.0.dist-info}/top_level.txt +0 -0
alma/graph/backends/neo4j.py
CHANGED
|
@@ -1,417 +1,417 @@
|
|
|
1
|
-
"""
|
|
2
|
-
ALMA Graph Memory - Neo4j Backend.
|
|
3
|
-
|
|
4
|
-
Neo4j implementation of the GraphBackend interface.
|
|
5
|
-
"""
|
|
6
|
-
|
|
7
|
-
import json
|
|
8
|
-
import logging
|
|
9
|
-
from datetime import datetime, timezone
|
|
10
|
-
from typing import Any, Dict, List, Optional
|
|
11
|
-
|
|
12
|
-
from alma.graph.base import GraphBackend
|
|
13
|
-
from alma.graph.store import Entity, Relationship
|
|
14
|
-
|
|
15
|
-
logger = logging.getLogger(__name__)
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
class Neo4jBackend(GraphBackend):
|
|
19
|
-
"""
|
|
20
|
-
Neo4j graph database backend.
|
|
21
|
-
|
|
22
|
-
Requires neo4j Python driver: pip install neo4j
|
|
23
|
-
|
|
24
|
-
Example usage:
|
|
25
|
-
backend = Neo4jBackend(
|
|
26
|
-
uri="bolt://localhost:7687",
|
|
27
|
-
username="neo4j",
|
|
28
|
-
password="password"
|
|
29
|
-
)
|
|
30
|
-
backend.add_entity(entity)
|
|
31
|
-
backend.close()
|
|
32
|
-
"""
|
|
33
|
-
|
|
34
|
-
def __init__(
|
|
35
|
-
self,
|
|
36
|
-
uri: str,
|
|
37
|
-
username: str,
|
|
38
|
-
password: str,
|
|
39
|
-
database: str = "neo4j",
|
|
40
|
-
):
|
|
41
|
-
"""
|
|
42
|
-
Initialize Neo4j connection.
|
|
43
|
-
|
|
44
|
-
Args:
|
|
45
|
-
uri: Neo4j connection URI (bolt:// or neo4j+s://)
|
|
46
|
-
username: Database username
|
|
47
|
-
password: Database password
|
|
48
|
-
database: Database name (default: "neo4j")
|
|
49
|
-
"""
|
|
50
|
-
self.uri = uri
|
|
51
|
-
self.username = username
|
|
52
|
-
self.password = password
|
|
53
|
-
self.database = database
|
|
54
|
-
self._driver = None
|
|
55
|
-
|
|
56
|
-
def _get_driver(self):
|
|
57
|
-
"""Lazy initialization of Neo4j driver."""
|
|
58
|
-
if self._driver is None:
|
|
59
|
-
try:
|
|
60
|
-
from neo4j import GraphDatabase
|
|
61
|
-
|
|
62
|
-
self._driver = GraphDatabase.driver(
|
|
63
|
-
self.uri,
|
|
64
|
-
auth=(self.username, self.password),
|
|
65
|
-
)
|
|
66
|
-
except ImportError as err:
|
|
67
|
-
raise ImportError(
|
|
68
|
-
"neo4j package required for Neo4j graph backend. "
|
|
69
|
-
"Install with: pip install neo4j"
|
|
70
|
-
) from err
|
|
71
|
-
return self._driver
|
|
72
|
-
|
|
73
|
-
def _run_query(self, query: str, parameters: Optional[Dict] = None) -> List[Dict]:
|
|
74
|
-
"""Execute a Cypher query."""
|
|
75
|
-
driver = self._get_driver()
|
|
76
|
-
with driver.session(database=self.database) as session:
|
|
77
|
-
result = session.run(query, parameters or {})
|
|
78
|
-
return [dict(record) for record in result]
|
|
79
|
-
|
|
80
|
-
def add_entity(self, entity: Entity) -> str:
|
|
81
|
-
"""Add or update an entity in Neo4j."""
|
|
82
|
-
# Extract project_id and agent from properties if present
|
|
83
|
-
properties = entity.properties.copy()
|
|
84
|
-
project_id = properties.pop("project_id", None)
|
|
85
|
-
agent = properties.pop("agent", None)
|
|
86
|
-
|
|
87
|
-
query = """
|
|
88
|
-
MERGE (e:Entity {id: $id})
|
|
89
|
-
SET e.name = $name,
|
|
90
|
-
e.entity_type = $entity_type,
|
|
91
|
-
e.properties = $properties,
|
|
92
|
-
e.created_at = $created_at
|
|
93
|
-
"""
|
|
94
|
-
params = {
|
|
95
|
-
"id": entity.id,
|
|
96
|
-
"name": entity.name,
|
|
97
|
-
"entity_type": entity.entity_type,
|
|
98
|
-
"properties": json.dumps(properties),
|
|
99
|
-
"created_at": entity.created_at.isoformat(),
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
# Add optional fields if present
|
|
103
|
-
if project_id:
|
|
104
|
-
query += ", e.project_id = $project_id"
|
|
105
|
-
params["project_id"] = project_id
|
|
106
|
-
if agent:
|
|
107
|
-
query += ", e.agent = $agent"
|
|
108
|
-
params["agent"] = agent
|
|
109
|
-
|
|
110
|
-
query += " RETURN e.id as id"
|
|
111
|
-
|
|
112
|
-
result = self._run_query(query, params)
|
|
113
|
-
return result[0]["id"] if result else entity.id
|
|
114
|
-
|
|
115
|
-
def add_relationship(self, relationship: Relationship) -> str:
|
|
116
|
-
"""Add or update a relationship in Neo4j."""
|
|
117
|
-
# Sanitize relationship type for Cypher (remove special characters)
|
|
118
|
-
rel_type = (
|
|
119
|
-
relationship.relation_type.replace("-", "_").replace(" ", "_").upper()
|
|
120
|
-
)
|
|
121
|
-
|
|
122
|
-
query = f"""
|
|
123
|
-
MATCH (source:Entity {{id: $source_id}})
|
|
124
|
-
MATCH (target:Entity {{id: $target_id}})
|
|
125
|
-
MERGE (source)-[r:{rel_type}]->(target)
|
|
126
|
-
SET r.id = $id,
|
|
127
|
-
r.properties = $properties,
|
|
128
|
-
r.confidence = $confidence,
|
|
129
|
-
r.created_at = $created_at
|
|
130
|
-
RETURN r.id as id
|
|
131
|
-
"""
|
|
132
|
-
result = self._run_query(
|
|
133
|
-
query,
|
|
134
|
-
{
|
|
135
|
-
"id": relationship.id,
|
|
136
|
-
"source_id": relationship.source_id,
|
|
137
|
-
"target_id": relationship.target_id,
|
|
138
|
-
"properties": json.dumps(relationship.properties),
|
|
139
|
-
"confidence": relationship.confidence,
|
|
140
|
-
"created_at": relationship.created_at.isoformat(),
|
|
141
|
-
},
|
|
142
|
-
)
|
|
143
|
-
return result[0]["id"] if result else relationship.id
|
|
144
|
-
|
|
145
|
-
def get_entity(self, entity_id: str) -> Optional[Entity]:
|
|
146
|
-
"""Get an entity by ID."""
|
|
147
|
-
query = """
|
|
148
|
-
MATCH (e:Entity {id: $id})
|
|
149
|
-
RETURN e.id as id, e.name as name, e.entity_type as entity_type,
|
|
150
|
-
e.properties as properties, e.created_at as created_at,
|
|
151
|
-
e.project_id as project_id, e.agent as agent
|
|
152
|
-
"""
|
|
153
|
-
result = self._run_query(query, {"id": entity_id})
|
|
154
|
-
if not result:
|
|
155
|
-
return None
|
|
156
|
-
|
|
157
|
-
r = result[0]
|
|
158
|
-
properties = json.loads(r["properties"]) if r["properties"] else {}
|
|
159
|
-
|
|
160
|
-
# Add project_id and agent back to properties if present
|
|
161
|
-
if r.get("project_id"):
|
|
162
|
-
properties["project_id"] = r["project_id"]
|
|
163
|
-
if r.get("agent"):
|
|
164
|
-
properties["agent"] = r["agent"]
|
|
165
|
-
|
|
166
|
-
return Entity(
|
|
167
|
-
id=r["id"],
|
|
168
|
-
name=r["name"],
|
|
169
|
-
entity_type=r["entity_type"],
|
|
170
|
-
properties=properties,
|
|
171
|
-
created_at=(
|
|
172
|
-
datetime.fromisoformat(r["created_at"])
|
|
173
|
-
if r["created_at"]
|
|
174
|
-
else datetime.now(timezone.utc)
|
|
175
|
-
),
|
|
176
|
-
)
|
|
177
|
-
|
|
178
|
-
def get_entities(
|
|
179
|
-
self,
|
|
180
|
-
entity_type: Optional[str] = None,
|
|
181
|
-
project_id: Optional[str] = None,
|
|
182
|
-
agent: Optional[str] = None,
|
|
183
|
-
limit: int = 100,
|
|
184
|
-
) -> List[Entity]:
|
|
185
|
-
"""Get entities with optional filtering."""
|
|
186
|
-
conditions = []
|
|
187
|
-
params: Dict[str, Any] = {"limit": limit}
|
|
188
|
-
|
|
189
|
-
if entity_type:
|
|
190
|
-
conditions.append("e.entity_type = $entity_type")
|
|
191
|
-
params["entity_type"] = entity_type
|
|
192
|
-
if project_id:
|
|
193
|
-
conditions.append("e.project_id = $project_id")
|
|
194
|
-
params["project_id"] = project_id
|
|
195
|
-
if agent:
|
|
196
|
-
conditions.append("e.agent = $agent")
|
|
197
|
-
params["agent"] = agent
|
|
198
|
-
|
|
199
|
-
where_clause = f"WHERE {' AND '.join(conditions)}" if conditions else ""
|
|
200
|
-
|
|
201
|
-
query = f"""
|
|
202
|
-
MATCH (e:Entity)
|
|
203
|
-
{where_clause}
|
|
204
|
-
RETURN e.id as id, e.name as name, e.entity_type as entity_type,
|
|
205
|
-
e.properties as properties, e.created_at as created_at,
|
|
206
|
-
e.project_id as project_id, e.agent as agent
|
|
207
|
-
LIMIT $limit
|
|
208
|
-
"""
|
|
209
|
-
|
|
210
|
-
results = self._run_query(query, params)
|
|
211
|
-
entities = []
|
|
212
|
-
for r in results:
|
|
213
|
-
properties = json.loads(r["properties"]) if r["properties"] else {}
|
|
214
|
-
if r.get("project_id"):
|
|
215
|
-
properties["project_id"] = r["project_id"]
|
|
216
|
-
if r.get("agent"):
|
|
217
|
-
properties["agent"] = r["agent"]
|
|
218
|
-
|
|
219
|
-
entities.append(
|
|
220
|
-
Entity(
|
|
221
|
-
id=r["id"],
|
|
222
|
-
name=r["name"],
|
|
223
|
-
entity_type=r["entity_type"],
|
|
224
|
-
properties=properties,
|
|
225
|
-
created_at=(
|
|
226
|
-
datetime.fromisoformat(r["created_at"])
|
|
227
|
-
if r["created_at"]
|
|
228
|
-
else datetime.now(timezone.utc)
|
|
229
|
-
),
|
|
230
|
-
)
|
|
231
|
-
)
|
|
232
|
-
return entities
|
|
233
|
-
|
|
234
|
-
def get_relationships(self, entity_id: str) -> List[Relationship]:
|
|
235
|
-
"""Get all relationships for an entity (both directions)."""
|
|
236
|
-
query = """
|
|
237
|
-
MATCH (e:Entity {id: $entity_id})-[r]-(other:Entity)
|
|
238
|
-
RETURN r.id as id,
|
|
239
|
-
CASE WHEN startNode(r).id = $entity_id THEN e.id ELSE other.id END as source_id,
|
|
240
|
-
CASE WHEN endNode(r).id = $entity_id THEN e.id ELSE other.id END as target_id,
|
|
241
|
-
type(r) as relation_type, r.properties as properties,
|
|
242
|
-
r.confidence as confidence, r.created_at as created_at
|
|
243
|
-
"""
|
|
244
|
-
|
|
245
|
-
results = self._run_query(query, {"entity_id": entity_id})
|
|
246
|
-
relationships = []
|
|
247
|
-
for r in results:
|
|
248
|
-
rel_id = (
|
|
249
|
-
r["id"] or f"{r['source_id']}-{r['relation_type']}-{r['target_id']}"
|
|
250
|
-
)
|
|
251
|
-
relationships.append(
|
|
252
|
-
Relationship(
|
|
253
|
-
id=rel_id,
|
|
254
|
-
source_id=r["source_id"],
|
|
255
|
-
target_id=r["target_id"],
|
|
256
|
-
relation_type=r["relation_type"],
|
|
257
|
-
properties=json.loads(r["properties"]) if r["properties"] else {},
|
|
258
|
-
confidence=r["confidence"] or 1.0,
|
|
259
|
-
created_at=(
|
|
260
|
-
datetime.fromisoformat(r["created_at"])
|
|
261
|
-
if r["created_at"]
|
|
262
|
-
else datetime.now(timezone.utc)
|
|
263
|
-
),
|
|
264
|
-
)
|
|
265
|
-
)
|
|
266
|
-
return relationships
|
|
267
|
-
|
|
268
|
-
def search_entities(
|
|
269
|
-
self,
|
|
270
|
-
query: str,
|
|
271
|
-
embedding: Optional[List[float]] = None,
|
|
272
|
-
top_k: int = 10,
|
|
273
|
-
) -> List[Entity]:
|
|
274
|
-
"""
|
|
275
|
-
Search for entities by name.
|
|
276
|
-
|
|
277
|
-
Note: Vector similarity search requires Neo4j 5.x with vector index.
|
|
278
|
-
Falls back to text search if embedding is provided but vector index
|
|
279
|
-
is not available.
|
|
280
|
-
"""
|
|
281
|
-
# For now, we do text-based search
|
|
282
|
-
# Vector search can be added when Neo4j vector indexes are set up
|
|
283
|
-
cypher = """
|
|
284
|
-
MATCH (e:Entity)
|
|
285
|
-
WHERE toLower(e.name) CONTAINS toLower($query)
|
|
286
|
-
RETURN e.id as id, e.name as name, e.entity_type as entity_type,
|
|
287
|
-
e.properties as properties, e.created_at as created_at,
|
|
288
|
-
e.project_id as project_id, e.agent as agent
|
|
289
|
-
LIMIT $limit
|
|
290
|
-
"""
|
|
291
|
-
|
|
292
|
-
results = self._run_query(cypher, {"query": query, "limit": top_k})
|
|
293
|
-
entities = []
|
|
294
|
-
for r in results:
|
|
295
|
-
properties = json.loads(r["properties"]) if r["properties"] else {}
|
|
296
|
-
if r.get("project_id"):
|
|
297
|
-
properties["project_id"] = r["project_id"]
|
|
298
|
-
if r.get("agent"):
|
|
299
|
-
properties["agent"] = r["agent"]
|
|
300
|
-
|
|
301
|
-
entities.append(
|
|
302
|
-
Entity(
|
|
303
|
-
id=r["id"],
|
|
304
|
-
name=r["name"],
|
|
305
|
-
entity_type=r["entity_type"],
|
|
306
|
-
properties=properties,
|
|
307
|
-
created_at=(
|
|
308
|
-
datetime.fromisoformat(r["created_at"])
|
|
309
|
-
if r["created_at"]
|
|
310
|
-
else datetime.now(timezone.utc)
|
|
311
|
-
),
|
|
312
|
-
)
|
|
313
|
-
)
|
|
314
|
-
return entities
|
|
315
|
-
|
|
316
|
-
def delete_entity(self, entity_id: str) -> bool:
|
|
317
|
-
"""Delete an entity and its relationships."""
|
|
318
|
-
query = """
|
|
319
|
-
MATCH (e:Entity {id: $id})
|
|
320
|
-
DETACH DELETE e
|
|
321
|
-
RETURN count(e) as deleted
|
|
322
|
-
"""
|
|
323
|
-
result = self._run_query(query, {"id": entity_id})
|
|
324
|
-
return result[0]["deleted"] > 0 if result else False
|
|
325
|
-
|
|
326
|
-
def delete_relationship(self, relationship_id: str) -> bool:
|
|
327
|
-
"""Delete a specific relationship by ID."""
|
|
328
|
-
query = """
|
|
329
|
-
MATCH ()-[r]-()
|
|
330
|
-
WHERE r.id = $id
|
|
331
|
-
DELETE r
|
|
332
|
-
RETURN count(r) as deleted
|
|
333
|
-
"""
|
|
334
|
-
result = self._run_query(query, {"id": relationship_id})
|
|
335
|
-
return result[0]["deleted"] > 0 if result else False
|
|
336
|
-
|
|
337
|
-
def close(self) -> None:
|
|
338
|
-
"""Close the Neo4j driver connection."""
|
|
339
|
-
if self._driver:
|
|
340
|
-
self._driver.close()
|
|
341
|
-
self._driver = None
|
|
342
|
-
|
|
343
|
-
# Additional methods for compatibility with existing GraphStore API
|
|
344
|
-
|
|
345
|
-
def find_entities(
|
|
346
|
-
self,
|
|
347
|
-
name: Optional[str] = None,
|
|
348
|
-
entity_type: Optional[str] = None,
|
|
349
|
-
limit: int = 10,
|
|
350
|
-
) -> List[Entity]:
|
|
351
|
-
"""
|
|
352
|
-
Find entities by name or type.
|
|
353
|
-
|
|
354
|
-
This method provides compatibility with the existing GraphStore API.
|
|
355
|
-
"""
|
|
356
|
-
if name:
|
|
357
|
-
return self.search_entities(query=name, top_k=limit)
|
|
358
|
-
|
|
359
|
-
return self.get_entities(entity_type=entity_type, limit=limit)
|
|
360
|
-
|
|
361
|
-
def get_relationships_directional(
|
|
362
|
-
self,
|
|
363
|
-
entity_id: str,
|
|
364
|
-
direction: str = "both",
|
|
365
|
-
relation_type: Optional[str] = None,
|
|
366
|
-
) -> List[Relationship]:
|
|
367
|
-
"""
|
|
368
|
-
Get relationships for an entity with direction control.
|
|
369
|
-
|
|
370
|
-
This method provides compatibility with the existing GraphStore API.
|
|
371
|
-
|
|
372
|
-
Args:
|
|
373
|
-
entity_id: The entity ID.
|
|
374
|
-
direction: "outgoing", "incoming", or "both".
|
|
375
|
-
relation_type: Optional filter by relationship type.
|
|
376
|
-
|
|
377
|
-
Returns:
|
|
378
|
-
List of matching relationships.
|
|
379
|
-
"""
|
|
380
|
-
if direction == "outgoing":
|
|
381
|
-
pattern = "(e)-[r]->(other)"
|
|
382
|
-
elif direction == "incoming":
|
|
383
|
-
pattern = "(e)<-[r]-(other)"
|
|
384
|
-
else:
|
|
385
|
-
pattern = "(e)-[r]-(other)"
|
|
386
|
-
|
|
387
|
-
type_filter = f":{relation_type}" if relation_type else ""
|
|
388
|
-
|
|
389
|
-
query = f"""
|
|
390
|
-
MATCH (e:Entity {{id: $entity_id}}){pattern.replace("[r]", f"[r{type_filter}]")}
|
|
391
|
-
RETURN r.id as id, e.id as source_id, other.id as target_id,
|
|
392
|
-
type(r) as relation_type, r.properties as properties,
|
|
393
|
-
r.confidence as confidence, r.created_at as created_at
|
|
394
|
-
"""
|
|
395
|
-
|
|
396
|
-
results = self._run_query(query, {"entity_id": entity_id})
|
|
397
|
-
relationships = []
|
|
398
|
-
for r in results:
|
|
399
|
-
rel_id = (
|
|
400
|
-
r["id"] or f"{r['source_id']}-{r['relation_type']}-{r['target_id']}"
|
|
401
|
-
)
|
|
402
|
-
relationships.append(
|
|
403
|
-
Relationship(
|
|
404
|
-
id=rel_id,
|
|
405
|
-
source_id=r["source_id"],
|
|
406
|
-
target_id=r["target_id"],
|
|
407
|
-
relation_type=r["relation_type"],
|
|
408
|
-
properties=json.loads(r["properties"]) if r["properties"] else {},
|
|
409
|
-
confidence=r["confidence"] or 1.0,
|
|
410
|
-
created_at=(
|
|
411
|
-
datetime.fromisoformat(r["created_at"])
|
|
412
|
-
if r["created_at"]
|
|
413
|
-
else datetime.now(timezone.utc)
|
|
414
|
-
),
|
|
415
|
-
)
|
|
416
|
-
)
|
|
417
|
-
return relationships
|
|
1
|
+
"""
|
|
2
|
+
ALMA Graph Memory - Neo4j Backend.
|
|
3
|
+
|
|
4
|
+
Neo4j implementation of the GraphBackend interface.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from typing import Any, Dict, List, Optional
|
|
11
|
+
|
|
12
|
+
from alma.graph.base import GraphBackend
|
|
13
|
+
from alma.graph.store import Entity, Relationship
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Neo4jBackend(GraphBackend):
|
|
19
|
+
"""
|
|
20
|
+
Neo4j graph database backend.
|
|
21
|
+
|
|
22
|
+
Requires neo4j Python driver: pip install neo4j
|
|
23
|
+
|
|
24
|
+
Example usage:
|
|
25
|
+
backend = Neo4jBackend(
|
|
26
|
+
uri="bolt://localhost:7687",
|
|
27
|
+
username="neo4j",
|
|
28
|
+
password="password"
|
|
29
|
+
)
|
|
30
|
+
backend.add_entity(entity)
|
|
31
|
+
backend.close()
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
uri: str,
|
|
37
|
+
username: str,
|
|
38
|
+
password: str,
|
|
39
|
+
database: str = "neo4j",
|
|
40
|
+
):
|
|
41
|
+
"""
|
|
42
|
+
Initialize Neo4j connection.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
uri: Neo4j connection URI (bolt:// or neo4j+s://)
|
|
46
|
+
username: Database username
|
|
47
|
+
password: Database password
|
|
48
|
+
database: Database name (default: "neo4j")
|
|
49
|
+
"""
|
|
50
|
+
self.uri = uri
|
|
51
|
+
self.username = username
|
|
52
|
+
self.password = password
|
|
53
|
+
self.database = database
|
|
54
|
+
self._driver = None
|
|
55
|
+
|
|
56
|
+
def _get_driver(self):
|
|
57
|
+
"""Lazy initialization of Neo4j driver."""
|
|
58
|
+
if self._driver is None:
|
|
59
|
+
try:
|
|
60
|
+
from neo4j import GraphDatabase
|
|
61
|
+
|
|
62
|
+
self._driver = GraphDatabase.driver(
|
|
63
|
+
self.uri,
|
|
64
|
+
auth=(self.username, self.password),
|
|
65
|
+
)
|
|
66
|
+
except ImportError as err:
|
|
67
|
+
raise ImportError(
|
|
68
|
+
"neo4j package required for Neo4j graph backend. "
|
|
69
|
+
"Install with: pip install neo4j"
|
|
70
|
+
) from err
|
|
71
|
+
return self._driver
|
|
72
|
+
|
|
73
|
+
def _run_query(self, query: str, parameters: Optional[Dict] = None) -> List[Dict]:
|
|
74
|
+
"""Execute a Cypher query."""
|
|
75
|
+
driver = self._get_driver()
|
|
76
|
+
with driver.session(database=self.database) as session:
|
|
77
|
+
result = session.run(query, parameters or {})
|
|
78
|
+
return [dict(record) for record in result]
|
|
79
|
+
|
|
80
|
+
def add_entity(self, entity: Entity) -> str:
|
|
81
|
+
"""Add or update an entity in Neo4j."""
|
|
82
|
+
# Extract project_id and agent from properties if present
|
|
83
|
+
properties = entity.properties.copy()
|
|
84
|
+
project_id = properties.pop("project_id", None)
|
|
85
|
+
agent = properties.pop("agent", None)
|
|
86
|
+
|
|
87
|
+
query = """
|
|
88
|
+
MERGE (e:Entity {id: $id})
|
|
89
|
+
SET e.name = $name,
|
|
90
|
+
e.entity_type = $entity_type,
|
|
91
|
+
e.properties = $properties,
|
|
92
|
+
e.created_at = $created_at
|
|
93
|
+
"""
|
|
94
|
+
params = {
|
|
95
|
+
"id": entity.id,
|
|
96
|
+
"name": entity.name,
|
|
97
|
+
"entity_type": entity.entity_type,
|
|
98
|
+
"properties": json.dumps(properties),
|
|
99
|
+
"created_at": entity.created_at.isoformat(),
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
# Add optional fields if present
|
|
103
|
+
if project_id:
|
|
104
|
+
query += ", e.project_id = $project_id"
|
|
105
|
+
params["project_id"] = project_id
|
|
106
|
+
if agent:
|
|
107
|
+
query += ", e.agent = $agent"
|
|
108
|
+
params["agent"] = agent
|
|
109
|
+
|
|
110
|
+
query += " RETURN e.id as id"
|
|
111
|
+
|
|
112
|
+
result = self._run_query(query, params)
|
|
113
|
+
return result[0]["id"] if result else entity.id
|
|
114
|
+
|
|
115
|
+
def add_relationship(self, relationship: Relationship) -> str:
|
|
116
|
+
"""Add or update a relationship in Neo4j."""
|
|
117
|
+
# Sanitize relationship type for Cypher (remove special characters)
|
|
118
|
+
rel_type = (
|
|
119
|
+
relationship.relation_type.replace("-", "_").replace(" ", "_").upper()
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
query = f"""
|
|
123
|
+
MATCH (source:Entity {{id: $source_id}})
|
|
124
|
+
MATCH (target:Entity {{id: $target_id}})
|
|
125
|
+
MERGE (source)-[r:{rel_type}]->(target)
|
|
126
|
+
SET r.id = $id,
|
|
127
|
+
r.properties = $properties,
|
|
128
|
+
r.confidence = $confidence,
|
|
129
|
+
r.created_at = $created_at
|
|
130
|
+
RETURN r.id as id
|
|
131
|
+
"""
|
|
132
|
+
result = self._run_query(
|
|
133
|
+
query,
|
|
134
|
+
{
|
|
135
|
+
"id": relationship.id,
|
|
136
|
+
"source_id": relationship.source_id,
|
|
137
|
+
"target_id": relationship.target_id,
|
|
138
|
+
"properties": json.dumps(relationship.properties),
|
|
139
|
+
"confidence": relationship.confidence,
|
|
140
|
+
"created_at": relationship.created_at.isoformat(),
|
|
141
|
+
},
|
|
142
|
+
)
|
|
143
|
+
return result[0]["id"] if result else relationship.id
|
|
144
|
+
|
|
145
|
+
def get_entity(self, entity_id: str) -> Optional[Entity]:
|
|
146
|
+
"""Get an entity by ID."""
|
|
147
|
+
query = """
|
|
148
|
+
MATCH (e:Entity {id: $id})
|
|
149
|
+
RETURN e.id as id, e.name as name, e.entity_type as entity_type,
|
|
150
|
+
e.properties as properties, e.created_at as created_at,
|
|
151
|
+
e.project_id as project_id, e.agent as agent
|
|
152
|
+
"""
|
|
153
|
+
result = self._run_query(query, {"id": entity_id})
|
|
154
|
+
if not result:
|
|
155
|
+
return None
|
|
156
|
+
|
|
157
|
+
r = result[0]
|
|
158
|
+
properties = json.loads(r["properties"]) if r["properties"] else {}
|
|
159
|
+
|
|
160
|
+
# Add project_id and agent back to properties if present
|
|
161
|
+
if r.get("project_id"):
|
|
162
|
+
properties["project_id"] = r["project_id"]
|
|
163
|
+
if r.get("agent"):
|
|
164
|
+
properties["agent"] = r["agent"]
|
|
165
|
+
|
|
166
|
+
return Entity(
|
|
167
|
+
id=r["id"],
|
|
168
|
+
name=r["name"],
|
|
169
|
+
entity_type=r["entity_type"],
|
|
170
|
+
properties=properties,
|
|
171
|
+
created_at=(
|
|
172
|
+
datetime.fromisoformat(r["created_at"])
|
|
173
|
+
if r["created_at"]
|
|
174
|
+
else datetime.now(timezone.utc)
|
|
175
|
+
),
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
def get_entities(
|
|
179
|
+
self,
|
|
180
|
+
entity_type: Optional[str] = None,
|
|
181
|
+
project_id: Optional[str] = None,
|
|
182
|
+
agent: Optional[str] = None,
|
|
183
|
+
limit: int = 100,
|
|
184
|
+
) -> List[Entity]:
|
|
185
|
+
"""Get entities with optional filtering."""
|
|
186
|
+
conditions = []
|
|
187
|
+
params: Dict[str, Any] = {"limit": limit}
|
|
188
|
+
|
|
189
|
+
if entity_type:
|
|
190
|
+
conditions.append("e.entity_type = $entity_type")
|
|
191
|
+
params["entity_type"] = entity_type
|
|
192
|
+
if project_id:
|
|
193
|
+
conditions.append("e.project_id = $project_id")
|
|
194
|
+
params["project_id"] = project_id
|
|
195
|
+
if agent:
|
|
196
|
+
conditions.append("e.agent = $agent")
|
|
197
|
+
params["agent"] = agent
|
|
198
|
+
|
|
199
|
+
where_clause = f"WHERE {' AND '.join(conditions)}" if conditions else ""
|
|
200
|
+
|
|
201
|
+
query = f"""
|
|
202
|
+
MATCH (e:Entity)
|
|
203
|
+
{where_clause}
|
|
204
|
+
RETURN e.id as id, e.name as name, e.entity_type as entity_type,
|
|
205
|
+
e.properties as properties, e.created_at as created_at,
|
|
206
|
+
e.project_id as project_id, e.agent as agent
|
|
207
|
+
LIMIT $limit
|
|
208
|
+
"""
|
|
209
|
+
|
|
210
|
+
results = self._run_query(query, params)
|
|
211
|
+
entities = []
|
|
212
|
+
for r in results:
|
|
213
|
+
properties = json.loads(r["properties"]) if r["properties"] else {}
|
|
214
|
+
if r.get("project_id"):
|
|
215
|
+
properties["project_id"] = r["project_id"]
|
|
216
|
+
if r.get("agent"):
|
|
217
|
+
properties["agent"] = r["agent"]
|
|
218
|
+
|
|
219
|
+
entities.append(
|
|
220
|
+
Entity(
|
|
221
|
+
id=r["id"],
|
|
222
|
+
name=r["name"],
|
|
223
|
+
entity_type=r["entity_type"],
|
|
224
|
+
properties=properties,
|
|
225
|
+
created_at=(
|
|
226
|
+
datetime.fromisoformat(r["created_at"])
|
|
227
|
+
if r["created_at"]
|
|
228
|
+
else datetime.now(timezone.utc)
|
|
229
|
+
),
|
|
230
|
+
)
|
|
231
|
+
)
|
|
232
|
+
return entities
|
|
233
|
+
|
|
234
|
+
def get_relationships(self, entity_id: str) -> List[Relationship]:
|
|
235
|
+
"""Get all relationships for an entity (both directions)."""
|
|
236
|
+
query = """
|
|
237
|
+
MATCH (e:Entity {id: $entity_id})-[r]-(other:Entity)
|
|
238
|
+
RETURN r.id as id,
|
|
239
|
+
CASE WHEN startNode(r).id = $entity_id THEN e.id ELSE other.id END as source_id,
|
|
240
|
+
CASE WHEN endNode(r).id = $entity_id THEN e.id ELSE other.id END as target_id,
|
|
241
|
+
type(r) as relation_type, r.properties as properties,
|
|
242
|
+
r.confidence as confidence, r.created_at as created_at
|
|
243
|
+
"""
|
|
244
|
+
|
|
245
|
+
results = self._run_query(query, {"entity_id": entity_id})
|
|
246
|
+
relationships = []
|
|
247
|
+
for r in results:
|
|
248
|
+
rel_id = (
|
|
249
|
+
r["id"] or f"{r['source_id']}-{r['relation_type']}-{r['target_id']}"
|
|
250
|
+
)
|
|
251
|
+
relationships.append(
|
|
252
|
+
Relationship(
|
|
253
|
+
id=rel_id,
|
|
254
|
+
source_id=r["source_id"],
|
|
255
|
+
target_id=r["target_id"],
|
|
256
|
+
relation_type=r["relation_type"],
|
|
257
|
+
properties=json.loads(r["properties"]) if r["properties"] else {},
|
|
258
|
+
confidence=r["confidence"] or 1.0,
|
|
259
|
+
created_at=(
|
|
260
|
+
datetime.fromisoformat(r["created_at"])
|
|
261
|
+
if r["created_at"]
|
|
262
|
+
else datetime.now(timezone.utc)
|
|
263
|
+
),
|
|
264
|
+
)
|
|
265
|
+
)
|
|
266
|
+
return relationships
|
|
267
|
+
|
|
268
|
+
def search_entities(
|
|
269
|
+
self,
|
|
270
|
+
query: str,
|
|
271
|
+
embedding: Optional[List[float]] = None,
|
|
272
|
+
top_k: int = 10,
|
|
273
|
+
) -> List[Entity]:
|
|
274
|
+
"""
|
|
275
|
+
Search for entities by name.
|
|
276
|
+
|
|
277
|
+
Note: Vector similarity search requires Neo4j 5.x with vector index.
|
|
278
|
+
Falls back to text search if embedding is provided but vector index
|
|
279
|
+
is not available.
|
|
280
|
+
"""
|
|
281
|
+
# For now, we do text-based search
|
|
282
|
+
# Vector search can be added when Neo4j vector indexes are set up
|
|
283
|
+
cypher = """
|
|
284
|
+
MATCH (e:Entity)
|
|
285
|
+
WHERE toLower(e.name) CONTAINS toLower($query)
|
|
286
|
+
RETURN e.id as id, e.name as name, e.entity_type as entity_type,
|
|
287
|
+
e.properties as properties, e.created_at as created_at,
|
|
288
|
+
e.project_id as project_id, e.agent as agent
|
|
289
|
+
LIMIT $limit
|
|
290
|
+
"""
|
|
291
|
+
|
|
292
|
+
results = self._run_query(cypher, {"query": query, "limit": top_k})
|
|
293
|
+
entities = []
|
|
294
|
+
for r in results:
|
|
295
|
+
properties = json.loads(r["properties"]) if r["properties"] else {}
|
|
296
|
+
if r.get("project_id"):
|
|
297
|
+
properties["project_id"] = r["project_id"]
|
|
298
|
+
if r.get("agent"):
|
|
299
|
+
properties["agent"] = r["agent"]
|
|
300
|
+
|
|
301
|
+
entities.append(
|
|
302
|
+
Entity(
|
|
303
|
+
id=r["id"],
|
|
304
|
+
name=r["name"],
|
|
305
|
+
entity_type=r["entity_type"],
|
|
306
|
+
properties=properties,
|
|
307
|
+
created_at=(
|
|
308
|
+
datetime.fromisoformat(r["created_at"])
|
|
309
|
+
if r["created_at"]
|
|
310
|
+
else datetime.now(timezone.utc)
|
|
311
|
+
),
|
|
312
|
+
)
|
|
313
|
+
)
|
|
314
|
+
return entities
|
|
315
|
+
|
|
316
|
+
def delete_entity(self, entity_id: str) -> bool:
|
|
317
|
+
"""Delete an entity and its relationships."""
|
|
318
|
+
query = """
|
|
319
|
+
MATCH (e:Entity {id: $id})
|
|
320
|
+
DETACH DELETE e
|
|
321
|
+
RETURN count(e) as deleted
|
|
322
|
+
"""
|
|
323
|
+
result = self._run_query(query, {"id": entity_id})
|
|
324
|
+
return result[0]["deleted"] > 0 if result else False
|
|
325
|
+
|
|
326
|
+
def delete_relationship(self, relationship_id: str) -> bool:
|
|
327
|
+
"""Delete a specific relationship by ID."""
|
|
328
|
+
query = """
|
|
329
|
+
MATCH ()-[r]-()
|
|
330
|
+
WHERE r.id = $id
|
|
331
|
+
DELETE r
|
|
332
|
+
RETURN count(r) as deleted
|
|
333
|
+
"""
|
|
334
|
+
result = self._run_query(query, {"id": relationship_id})
|
|
335
|
+
return result[0]["deleted"] > 0 if result else False
|
|
336
|
+
|
|
337
|
+
def close(self) -> None:
|
|
338
|
+
"""Close the Neo4j driver connection."""
|
|
339
|
+
if self._driver:
|
|
340
|
+
self._driver.close()
|
|
341
|
+
self._driver = None
|
|
342
|
+
|
|
343
|
+
# Additional methods for compatibility with existing GraphStore API
|
|
344
|
+
|
|
345
|
+
def find_entities(
|
|
346
|
+
self,
|
|
347
|
+
name: Optional[str] = None,
|
|
348
|
+
entity_type: Optional[str] = None,
|
|
349
|
+
limit: int = 10,
|
|
350
|
+
) -> List[Entity]:
|
|
351
|
+
"""
|
|
352
|
+
Find entities by name or type.
|
|
353
|
+
|
|
354
|
+
This method provides compatibility with the existing GraphStore API.
|
|
355
|
+
"""
|
|
356
|
+
if name:
|
|
357
|
+
return self.search_entities(query=name, top_k=limit)
|
|
358
|
+
|
|
359
|
+
return self.get_entities(entity_type=entity_type, limit=limit)
|
|
360
|
+
|
|
361
|
+
def get_relationships_directional(
|
|
362
|
+
self,
|
|
363
|
+
entity_id: str,
|
|
364
|
+
direction: str = "both",
|
|
365
|
+
relation_type: Optional[str] = None,
|
|
366
|
+
) -> List[Relationship]:
|
|
367
|
+
"""
|
|
368
|
+
Get relationships for an entity with direction control.
|
|
369
|
+
|
|
370
|
+
This method provides compatibility with the existing GraphStore API.
|
|
371
|
+
|
|
372
|
+
Args:
|
|
373
|
+
entity_id: The entity ID.
|
|
374
|
+
direction: "outgoing", "incoming", or "both".
|
|
375
|
+
relation_type: Optional filter by relationship type.
|
|
376
|
+
|
|
377
|
+
Returns:
|
|
378
|
+
List of matching relationships.
|
|
379
|
+
"""
|
|
380
|
+
if direction == "outgoing":
|
|
381
|
+
pattern = "(e)-[r]->(other)"
|
|
382
|
+
elif direction == "incoming":
|
|
383
|
+
pattern = "(e)<-[r]-(other)"
|
|
384
|
+
else:
|
|
385
|
+
pattern = "(e)-[r]-(other)"
|
|
386
|
+
|
|
387
|
+
type_filter = f":{relation_type}" if relation_type else ""
|
|
388
|
+
|
|
389
|
+
query = f"""
|
|
390
|
+
MATCH (e:Entity {{id: $entity_id}}){pattern.replace("[r]", f"[r{type_filter}]")}
|
|
391
|
+
RETURN r.id as id, e.id as source_id, other.id as target_id,
|
|
392
|
+
type(r) as relation_type, r.properties as properties,
|
|
393
|
+
r.confidence as confidence, r.created_at as created_at
|
|
394
|
+
"""
|
|
395
|
+
|
|
396
|
+
results = self._run_query(query, {"entity_id": entity_id})
|
|
397
|
+
relationships = []
|
|
398
|
+
for r in results:
|
|
399
|
+
rel_id = (
|
|
400
|
+
r["id"] or f"{r['source_id']}-{r['relation_type']}-{r['target_id']}"
|
|
401
|
+
)
|
|
402
|
+
relationships.append(
|
|
403
|
+
Relationship(
|
|
404
|
+
id=rel_id,
|
|
405
|
+
source_id=r["source_id"],
|
|
406
|
+
target_id=r["target_id"],
|
|
407
|
+
relation_type=r["relation_type"],
|
|
408
|
+
properties=json.loads(r["properties"]) if r["properties"] else {},
|
|
409
|
+
confidence=r["confidence"] or 1.0,
|
|
410
|
+
created_at=(
|
|
411
|
+
datetime.fromisoformat(r["created_at"])
|
|
412
|
+
if r["created_at"]
|
|
413
|
+
else datetime.now(timezone.utc)
|
|
414
|
+
),
|
|
415
|
+
)
|
|
416
|
+
)
|
|
417
|
+
return relationships
|