deepagents-graph-memory 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.
- deepagents_graph_memory/__init__.py +20 -0
- deepagents_graph_memory/backend.py +838 -0
- deepagents_graph_memory/errors.py +21 -0
- deepagents_graph_memory/ladybug_store.py +847 -0
- deepagents_graph_memory/paths.py +253 -0
- deepagents_graph_memory/py.typed +1 -0
- deepagents_graph_memory/recall.py +936 -0
- deepagents_graph_memory/renderers.py +199 -0
- deepagents_graph_memory/stores.py +378 -0
- deepagents_graph_memory/tools.py +193 -0
- deepagents_graph_memory/vgs.py +210 -0
- deepagents_graph_memory-0.1.0.dist-info/METADATA +770 -0
- deepagents_graph_memory-0.1.0.dist-info/RECORD +16 -0
- deepagents_graph_memory-0.1.0.dist-info/WHEEL +5 -0
- deepagents_graph_memory-0.1.0.dist-info/licenses/LICENSE +21 -0
- deepagents_graph_memory-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,847 @@
|
|
|
1
|
+
# - Keeps graph items and their connections in LadybugDB, and finds them again when asked.
|
|
2
|
+
# - Keeps each workspace's context separate when a scope is set.
|
|
3
|
+
# - Tests: test_ladybug_integration.py checks saving and reading a connection;
|
|
4
|
+
# test_ladybug_search.py checks search, and test_scope.py checks separation.
|
|
5
|
+
|
|
6
|
+
"""LadybugDB adapter for graph memory."""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
from collections.abc import Sequence
|
|
13
|
+
from contextlib import contextmanager
|
|
14
|
+
from functools import wraps
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from threading import RLock
|
|
17
|
+
from typing import Any, cast
|
|
18
|
+
from weakref import WeakValueDictionary
|
|
19
|
+
|
|
20
|
+
from deepagents_graph_memory.errors import GraphMemoryConfigurationError, GraphMemoryValidationError
|
|
21
|
+
from deepagents_graph_memory.paths import node_path, validate_identifier, validate_node_id
|
|
22
|
+
from deepagents_graph_memory.stores import (
|
|
23
|
+
EdgeResult,
|
|
24
|
+
GraphEdge,
|
|
25
|
+
GraphNode,
|
|
26
|
+
LimitedResult,
|
|
27
|
+
NeighborhoodResult,
|
|
28
|
+
Properties,
|
|
29
|
+
SearchItem,
|
|
30
|
+
SearchResult,
|
|
31
|
+
finding_observed_timestamp,
|
|
32
|
+
lexical_search_score,
|
|
33
|
+
node_search_text,
|
|
34
|
+
utc_now,
|
|
35
|
+
valid_finding_link,
|
|
36
|
+
validate_properties,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
import ladybug
|
|
41
|
+
except ImportError as exc: # pragma: no cover - exercised when package is absent
|
|
42
|
+
raise ImportError("LadybugDB support requires the `ladybug` package.") from exc
|
|
43
|
+
|
|
44
|
+
# Guard against multiple writable handles to one file in this process.
|
|
45
|
+
_disk_lock = RLock()
|
|
46
|
+
_open_disk_stores: WeakValueDictionary[tuple[object, ...], LadybugGraphStore] = WeakValueDictionary()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class _LadybugGraph:
|
|
50
|
+
"""Minimal query and schema adapter over a `ladybug.Connection`.
|
|
51
|
+
|
|
52
|
+
Uses the native connection for query execution and schema reflection.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def __init__(self, database: Any) -> None:
|
|
56
|
+
self.conn = ladybug.Connection(database)
|
|
57
|
+
self.schema = ""
|
|
58
|
+
try:
|
|
59
|
+
self.refresh_schema()
|
|
60
|
+
except BaseException:
|
|
61
|
+
self.conn.close()
|
|
62
|
+
raise
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def get_schema(self) -> str:
|
|
66
|
+
"""Return the reflected graph schema text."""
|
|
67
|
+
return self.schema
|
|
68
|
+
|
|
69
|
+
def query(self, query: str, params: dict[str, Any] | None = None) -> list[dict[str, Any]]:
|
|
70
|
+
"""Execute a Cypher query and return rows as dictionaries."""
|
|
71
|
+
# Ladybug 0.20.3's implicit statement cache can crash on repeated writes.
|
|
72
|
+
statement = ladybug.PreparedStatement(self.conn, query, params) if params else None
|
|
73
|
+
try:
|
|
74
|
+
result = self.conn.execute(statement if statement is not None else query, params or {})
|
|
75
|
+
try:
|
|
76
|
+
column_names = result.get_column_names()
|
|
77
|
+
rows: list[dict[str, Any]] = []
|
|
78
|
+
while result.has_next():
|
|
79
|
+
rows.append(dict(zip(column_names, result.get_next(), strict=False)))
|
|
80
|
+
return rows
|
|
81
|
+
finally:
|
|
82
|
+
result.close()
|
|
83
|
+
finally:
|
|
84
|
+
if statement is not None:
|
|
85
|
+
statement.close()
|
|
86
|
+
|
|
87
|
+
def refresh_schema(self) -> None:
|
|
88
|
+
"""Reflect node and relationship tables into a schema string.
|
|
89
|
+
|
|
90
|
+
Uses public Cypher introspection (`SHOW_TABLES`, `TABLE_INFO`,
|
|
91
|
+
`SHOW_CONNECTION`) rather than ladybug's private `_get_*` methods, so the
|
|
92
|
+
adapter does not depend on unstable internals.
|
|
93
|
+
"""
|
|
94
|
+
tables = self.query("CALL SHOW_TABLES() RETURN *;")
|
|
95
|
+
node_labels = [row["name"] for row in tables if row["type"] == "NODE"]
|
|
96
|
+
rel_labels = [row["name"] for row in tables if row["type"] == "REL"]
|
|
97
|
+
|
|
98
|
+
node_properties = [{"properties": self._table_properties(label), "label": label} for label in node_labels]
|
|
99
|
+
rel_properties = [{"properties": self._table_properties(label), "label": label} for label in rel_labels]
|
|
100
|
+
relationships = [
|
|
101
|
+
f"(:{connection['source table name']})-[:{label}]->(:{connection['destination table name']})"
|
|
102
|
+
for label in rel_labels
|
|
103
|
+
for connection in self.query(f"CALL SHOW_CONNECTION('{label}') RETURN *;")
|
|
104
|
+
]
|
|
105
|
+
|
|
106
|
+
self.schema = f"Node properties: {node_properties}\nRelationships properties: {rel_properties}\nRelationships: {relationships}\n"
|
|
107
|
+
|
|
108
|
+
def _table_properties(self, table: str) -> list[tuple[str, str]]:
|
|
109
|
+
"""Return ``(name, type)`` pairs for a node or relationship table."""
|
|
110
|
+
return [(row["name"], row["type"]) for row in self.query(f"CALL TABLE_INFO('{table}') RETURN *;")]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _locked(method: Any) -> Any:
|
|
114
|
+
@wraps(method)
|
|
115
|
+
def wrapper(self: LadybugGraphStore, *args: Any, **kwargs: Any) -> Any:
|
|
116
|
+
with self._lock:
|
|
117
|
+
self._require_open()
|
|
118
|
+
return method(self, *args, **kwargs)
|
|
119
|
+
|
|
120
|
+
return wrapper
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _atomic(method: Any) -> Any:
|
|
124
|
+
@wraps(method)
|
|
125
|
+
def wrapper(self: LadybugGraphStore, *args: Any, **kwargs: Any) -> Any:
|
|
126
|
+
with self.transaction():
|
|
127
|
+
return method(self, *args, **kwargs)
|
|
128
|
+
|
|
129
|
+
return wrapper
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class LadybugGraphStore:
|
|
133
|
+
"""Internal adapter for the native LadybugDB graph connection."""
|
|
134
|
+
|
|
135
|
+
def __init__(self, graph: Any, *, database: Any = None) -> None:
|
|
136
|
+
"""Initialize the adapter.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
graph: Query and schema adapter over a LadybugDB connection.
|
|
140
|
+
database: Database owned by this store, if created by a factory.
|
|
141
|
+
"""
|
|
142
|
+
self.graph = graph
|
|
143
|
+
self._database = database
|
|
144
|
+
self._closed = False
|
|
145
|
+
self._disk_keys: tuple[tuple[object, ...], ...] = ()
|
|
146
|
+
self._fts_ready_labels: set[str] = set()
|
|
147
|
+
# ponytail: one store lock serializes shared-connection work; split connections if throughput matters.
|
|
148
|
+
self._lock = RLock()
|
|
149
|
+
self._transaction_depth = 0
|
|
150
|
+
self._rollback_only = False
|
|
151
|
+
|
|
152
|
+
@contextmanager
|
|
153
|
+
def transaction(self) -> Any:
|
|
154
|
+
"""Serialize and atomically group writes on the shared LadybugDB connection."""
|
|
155
|
+
with self._lock:
|
|
156
|
+
self._require_open()
|
|
157
|
+
outermost = self._transaction_depth == 0
|
|
158
|
+
if outermost:
|
|
159
|
+
self._query("BEGIN TRANSACTION;", {})
|
|
160
|
+
self._rollback_only = False
|
|
161
|
+
self._transaction_depth += 1
|
|
162
|
+
try:
|
|
163
|
+
yield
|
|
164
|
+
except BaseException:
|
|
165
|
+
self._rollback_only = True
|
|
166
|
+
raise
|
|
167
|
+
else:
|
|
168
|
+
if outermost and self._rollback_only:
|
|
169
|
+
msg = "A nested graph write failed; transaction was rolled back."
|
|
170
|
+
raise GraphMemoryConfigurationError(msg)
|
|
171
|
+
finally:
|
|
172
|
+
self._transaction_depth -= 1
|
|
173
|
+
if outermost:
|
|
174
|
+
if self._rollback_only:
|
|
175
|
+
try:
|
|
176
|
+
self._query("ROLLBACK;", {})
|
|
177
|
+
except GraphMemoryConfigurationError:
|
|
178
|
+
pass # Preserve the original write failure if LadybugDB already aborted.
|
|
179
|
+
self._fts_ready_labels.clear()
|
|
180
|
+
else:
|
|
181
|
+
try:
|
|
182
|
+
self._query("COMMIT;", {})
|
|
183
|
+
except GraphMemoryConfigurationError:
|
|
184
|
+
try:
|
|
185
|
+
self._query("ROLLBACK;", {})
|
|
186
|
+
except GraphMemoryConfigurationError:
|
|
187
|
+
pass
|
|
188
|
+
self._fts_ready_labels.clear()
|
|
189
|
+
self._rollback_only = False
|
|
190
|
+
raise
|
|
191
|
+
self._rollback_only = False
|
|
192
|
+
|
|
193
|
+
@classmethod
|
|
194
|
+
def memory(cls) -> LadybugGraphStore:
|
|
195
|
+
"""Create an in-memory LadybugDB graph store."""
|
|
196
|
+
return cls._open(":memory:")
|
|
197
|
+
|
|
198
|
+
@classmethod
|
|
199
|
+
def disk(cls, path: str | Path) -> LadybugGraphStore:
|
|
200
|
+
"""Create or reopen a graph at an existing filesystem parent."""
|
|
201
|
+
if not isinstance(path, str | Path):
|
|
202
|
+
msg = "Persistent graph path must be a string or pathlib.Path."
|
|
203
|
+
raise GraphMemoryConfigurationError(msg)
|
|
204
|
+
raw = str(path)
|
|
205
|
+
if not raw.strip() or raw == ":memory:" or "\x00" in raw or re.match(r"^[A-Za-z][A-Za-z0-9+.-]*://", raw):
|
|
206
|
+
msg = "Persistent graph path must be a nonempty local filesystem path, not a URL or :memory:."
|
|
207
|
+
raise GraphMemoryConfigurationError(msg)
|
|
208
|
+
filename = Path(path)
|
|
209
|
+
if not filename.parent.is_dir():
|
|
210
|
+
msg = f"Persistent graph path parent does not exist: {filename.parent}"
|
|
211
|
+
raise GraphMemoryConfigurationError(msg)
|
|
212
|
+
if filename.is_dir():
|
|
213
|
+
msg = f"Persistent graph path points to a directory, expected a database file: {filename}"
|
|
214
|
+
raise GraphMemoryConfigurationError(msg)
|
|
215
|
+
try:
|
|
216
|
+
with _disk_lock:
|
|
217
|
+
resolved = filename.resolve()
|
|
218
|
+
keys: tuple[tuple[object, ...], ...] = (("path", str(resolved)),)
|
|
219
|
+
if resolved.exists():
|
|
220
|
+
info = resolved.stat()
|
|
221
|
+
keys += (("inode", info.st_dev, info.st_ino),)
|
|
222
|
+
if any(key in _open_disk_stores for key in keys):
|
|
223
|
+
msg = f"LadybugDB graph at path {filename} is already open in this process; reuse the existing backend.store."
|
|
224
|
+
raise GraphMemoryConfigurationError(msg)
|
|
225
|
+
store = cls._open(str(resolved))
|
|
226
|
+
try:
|
|
227
|
+
info = resolved.stat()
|
|
228
|
+
store._disk_keys = (keys[0], ("inode", info.st_dev, info.st_ino))
|
|
229
|
+
for key in store._disk_keys:
|
|
230
|
+
_open_disk_stores[key] = store
|
|
231
|
+
return store
|
|
232
|
+
except BaseException:
|
|
233
|
+
store.close()
|
|
234
|
+
raise
|
|
235
|
+
except OSError as exc:
|
|
236
|
+
msg = f"Could not access persistent graph path {filename}: {exc}"
|
|
237
|
+
raise GraphMemoryConfigurationError(msg) from exc
|
|
238
|
+
|
|
239
|
+
@classmethod
|
|
240
|
+
def _open(cls, path: str) -> LadybugGraphStore:
|
|
241
|
+
try:
|
|
242
|
+
database = ladybug.Database(path)
|
|
243
|
+
except Exception as exc:
|
|
244
|
+
msg = f"Could not open LadybugDB graph at path {path!r}: {exc}"
|
|
245
|
+
raise GraphMemoryConfigurationError(msg) from exc
|
|
246
|
+
try:
|
|
247
|
+
return cls(_LadybugGraph(database), database=database)
|
|
248
|
+
except Exception as exc:
|
|
249
|
+
database.close()
|
|
250
|
+
msg = f"Could not initialize LadybugDB graph at path {path!r}: {exc}"
|
|
251
|
+
raise GraphMemoryConfigurationError(msg) from exc
|
|
252
|
+
except BaseException:
|
|
253
|
+
database.close()
|
|
254
|
+
raise
|
|
255
|
+
|
|
256
|
+
def close(self) -> None:
|
|
257
|
+
"""Release an owned LadybugDB connection and database."""
|
|
258
|
+
with self._lock:
|
|
259
|
+
if self._closed:
|
|
260
|
+
return
|
|
261
|
+
if self._transaction_depth:
|
|
262
|
+
msg = "Cannot close a graph store during an active transaction."
|
|
263
|
+
raise GraphMemoryConfigurationError(msg)
|
|
264
|
+
self._closed = True
|
|
265
|
+
if self._database is not None:
|
|
266
|
+
try:
|
|
267
|
+
self.graph.conn.close()
|
|
268
|
+
finally:
|
|
269
|
+
try:
|
|
270
|
+
self._database.close()
|
|
271
|
+
finally:
|
|
272
|
+
with _disk_lock:
|
|
273
|
+
for key in self._disk_keys:
|
|
274
|
+
if _open_disk_stores.get(key) is self:
|
|
275
|
+
del _open_disk_stores[key]
|
|
276
|
+
|
|
277
|
+
def _require_open(self) -> None:
|
|
278
|
+
if self._closed:
|
|
279
|
+
msg = "Graph store is closed. Create or reopen a backend before querying or writing."
|
|
280
|
+
raise GraphMemoryConfigurationError(msg)
|
|
281
|
+
|
|
282
|
+
@_locked
|
|
283
|
+
def get_schema(self, *, scope_key: str | None = None) -> str:
|
|
284
|
+
"""Return graph schema text."""
|
|
285
|
+
del scope_key
|
|
286
|
+
self._refresh_schema()
|
|
287
|
+
if not self._labels() and not self._relationships():
|
|
288
|
+
return "No graph schema has been created yet."
|
|
289
|
+
return self.graph.get_schema
|
|
290
|
+
|
|
291
|
+
@_locked
|
|
292
|
+
def list_labels(self, *, scope_key: str | None = None, limit: int = 50) -> LimitedResult:
|
|
293
|
+
"""List known node labels."""
|
|
294
|
+
labels = [label for label in self._labels() if self._label_has_nodes(label, scope_key=scope_key)]
|
|
295
|
+
return LimitedResult(items=labels[:limit], truncated=len(labels) > limit)
|
|
296
|
+
|
|
297
|
+
@_locked
|
|
298
|
+
def list_node_ids(self, label: str, *, scope_key: str | None = None, limit: int = 50) -> LimitedResult:
|
|
299
|
+
"""List ids for a node label."""
|
|
300
|
+
validate_identifier(label, field="label")
|
|
301
|
+
if label not in self._labels():
|
|
302
|
+
return LimitedResult(items=[])
|
|
303
|
+
scope_where, params = _scope_where("n", scope_key)
|
|
304
|
+
rows = self._query(
|
|
305
|
+
f"""
|
|
306
|
+
MATCH (n:{label})
|
|
307
|
+
WHERE {scope_where}
|
|
308
|
+
RETURN n.id AS id
|
|
309
|
+
ORDER BY id
|
|
310
|
+
LIMIT {int(limit) + 1}
|
|
311
|
+
""",
|
|
312
|
+
params,
|
|
313
|
+
)
|
|
314
|
+
ids = [str(row["id"]) for row in rows if row.get("id") is not None]
|
|
315
|
+
return LimitedResult(items=ids[:limit], truncated=len(ids) > limit)
|
|
316
|
+
|
|
317
|
+
@_locked
|
|
318
|
+
def list_subject_trace_ids(self, subject_id: str, *, scope_key: str | None = None, limit: int = 50) -> LimitedResult:
|
|
319
|
+
"""Rank one subject's findings before applying the output limit."""
|
|
320
|
+
validate_node_id(subject_id)
|
|
321
|
+
relationships = set(self._relationships())
|
|
322
|
+
if "Subject" not in self._labels() or "Trace" not in self._labels() or "ABOUT" not in relationships:
|
|
323
|
+
return LimitedResult(items=[])
|
|
324
|
+
rows = self._query(
|
|
325
|
+
"MATCH (t:Trace)-[:ABOUT]->(s:Subject {pk: $pk}) RETURN t",
|
|
326
|
+
{"pk": _node_pk("Subject", subject_id, scope_key)},
|
|
327
|
+
)
|
|
328
|
+
traces = {node.id: node for row in rows if (node := _coerce_node(row.get("t"), default_label="Trace", default_id="")).id}
|
|
329
|
+
predecessors: set[str] = set()
|
|
330
|
+
# ponytail: sort one subject in Python; use indexed ranking if subject histories grow large enough to measure.
|
|
331
|
+
for relationship in ("SUPERSEDES", "RESOLVES"):
|
|
332
|
+
if relationship not in relationships:
|
|
333
|
+
continue
|
|
334
|
+
links = self._query(
|
|
335
|
+
f"MATCH (a:Trace)-[:ABOUT]->(s:Subject {{pk: $pk}}) MATCH (a)-[:{relationship}]->(b:Trace) RETURN a, b",
|
|
336
|
+
{"pk": _node_pk("Subject", subject_id, scope_key)},
|
|
337
|
+
)
|
|
338
|
+
targets_by_source: dict[str, set[str]] = {}
|
|
339
|
+
for row in links:
|
|
340
|
+
source_id = str(row["a"]["id"])
|
|
341
|
+
target_id = str(row["b"]["id"])
|
|
342
|
+
source = traces.get(source_id)
|
|
343
|
+
target = traces.get(target_id)
|
|
344
|
+
if source is not None and target is not None and source.properties.get("subject") == target.properties.get("subject"):
|
|
345
|
+
targets_by_source.setdefault(source_id, set()).add(target_id)
|
|
346
|
+
for source_id, targets in targets_by_source.items():
|
|
347
|
+
for target_id in targets:
|
|
348
|
+
if valid_finding_link(traces[source_id], traces[target_id], relationship, reviewed_count=len(targets)):
|
|
349
|
+
predecessors.add(target_id)
|
|
350
|
+
|
|
351
|
+
def rank(trace: GraphNode) -> tuple[bool, bool, float, str]:
|
|
352
|
+
timestamp = finding_observed_timestamp(trace)
|
|
353
|
+
return (trace.id in predecessors, timestamp is None, -(timestamp or 0), trace.id)
|
|
354
|
+
|
|
355
|
+
ordered = sorted(traces.values(), key=rank)
|
|
356
|
+
return LimitedResult(items=[trace.id for trace in ordered[:limit]], truncated=len(ordered) > limit)
|
|
357
|
+
|
|
358
|
+
@_locked
|
|
359
|
+
def get_node(self, label: str, node_id: str, *, scope_key: str | None = None) -> GraphNode | None:
|
|
360
|
+
"""Return a single node."""
|
|
361
|
+
validate_identifier(label, field="label")
|
|
362
|
+
validate_node_id(node_id)
|
|
363
|
+
if label not in self._labels():
|
|
364
|
+
return None
|
|
365
|
+
rows = self._query(f"MATCH (n:{label} {{pk: $pk}}) RETURN n", {"pk": _node_pk(label, node_id, scope_key)})
|
|
366
|
+
if not rows:
|
|
367
|
+
return None
|
|
368
|
+
return _coerce_node(rows[0].get("n"), default_label=label, default_id=node_id)
|
|
369
|
+
|
|
370
|
+
@_locked
|
|
371
|
+
def get_neighbors(
|
|
372
|
+
self,
|
|
373
|
+
label: str,
|
|
374
|
+
node_id: str,
|
|
375
|
+
*,
|
|
376
|
+
scope_key: str | None = None,
|
|
377
|
+
depth: int = 1,
|
|
378
|
+
max_nodes: int = 50,
|
|
379
|
+
max_edges: int = 100,
|
|
380
|
+
) -> NeighborhoodResult | None:
|
|
381
|
+
"""Return a bounded node neighborhood."""
|
|
382
|
+
node = self.get_node(label, node_id, scope_key=scope_key)
|
|
383
|
+
if node is None:
|
|
384
|
+
return None
|
|
385
|
+
frontier = {(label, node_id)}
|
|
386
|
+
seen_nodes = {(label, node_id)}
|
|
387
|
+
collected: dict[tuple[str, str, str, str, str], GraphEdge] = {}
|
|
388
|
+
truncated_nodes = False
|
|
389
|
+
truncated_edges = False
|
|
390
|
+
|
|
391
|
+
for _level in range(max(depth, 1)):
|
|
392
|
+
next_frontier: set[tuple[str, str]] = set()
|
|
393
|
+
for frontier_label, frontier_id in frontier:
|
|
394
|
+
for edge in self._get_immediate_edges(frontier_label, frontier_id, scope_key=scope_key, limit=max_edges + 1):
|
|
395
|
+
key = (edge.source_label, edge.source_id, edge.relationship, edge.target_label, edge.target_id)
|
|
396
|
+
if key not in collected:
|
|
397
|
+
if len(collected) >= max_edges:
|
|
398
|
+
truncated_edges = True
|
|
399
|
+
continue
|
|
400
|
+
collected[key] = edge
|
|
401
|
+
for candidate in ((edge.source_label, edge.source_id), (edge.target_label, edge.target_id)):
|
|
402
|
+
if candidate in seen_nodes:
|
|
403
|
+
continue
|
|
404
|
+
if len(seen_nodes) >= max_nodes:
|
|
405
|
+
truncated_nodes = True
|
|
406
|
+
continue
|
|
407
|
+
seen_nodes.add(candidate)
|
|
408
|
+
next_frontier.add(candidate)
|
|
409
|
+
frontier = next_frontier
|
|
410
|
+
if not frontier:
|
|
411
|
+
break
|
|
412
|
+
|
|
413
|
+
edges = sorted(collected.values(), key=lambda edge: (edge.relationship, edge.source_label, edge.source_id, edge.target_label, edge.target_id))
|
|
414
|
+
return NeighborhoodResult(node=node, edges=edges, truncated_nodes=truncated_nodes, truncated_edges=truncated_edges)
|
|
415
|
+
|
|
416
|
+
@_locked
|
|
417
|
+
def list_trace_edges(
|
|
418
|
+
self, trace_id: str, relationship: str, *, incoming: bool = False, scope_key: str | None = None, limit: int = 50
|
|
419
|
+
) -> EdgeResult:
|
|
420
|
+
"""Read a trace relationship without spending its limit on component links."""
|
|
421
|
+
validate_node_id(trace_id)
|
|
422
|
+
validate_identifier(relationship, field="relationship")
|
|
423
|
+
if "Trace" not in self._labels() or relationship not in self._relationships():
|
|
424
|
+
return EdgeResult(items=[])
|
|
425
|
+
direction = (
|
|
426
|
+
f"(other:Trace)-[r:{relationship}]->(trace:Trace {{pk: $pk}})"
|
|
427
|
+
if incoming
|
|
428
|
+
else f"(trace:Trace {{pk: $pk}})-[r:{relationship}]->(other:Trace)"
|
|
429
|
+
)
|
|
430
|
+
source, target = ("other", "trace") if incoming else ("trace", "other")
|
|
431
|
+
rows = self._query(
|
|
432
|
+
f"MATCH {direction} RETURN {source} AS source, r, {target} AS target ORDER BY source.id, target.id LIMIT {int(limit) + 1}",
|
|
433
|
+
{"pk": _node_pk("Trace", trace_id, scope_key)},
|
|
434
|
+
)
|
|
435
|
+
edges = [edge for row in rows if (edge := _coerce_edge(row, source_key="source", target_key="target")) is not None]
|
|
436
|
+
edges.sort(key=lambda edge: (edge.source_id, edge.target_id))
|
|
437
|
+
return EdgeResult(items=edges[:limit], truncated=len(rows) > limit)
|
|
438
|
+
|
|
439
|
+
@_locked
|
|
440
|
+
def search(self, query: str, *, scope_key: str | None = None, limit: int = 20) -> SearchResult:
|
|
441
|
+
"""Search graph metadata."""
|
|
442
|
+
if self._transaction_depth:
|
|
443
|
+
msg = "search requires LadybugDB auto transaction mode."
|
|
444
|
+
raise GraphMemoryConfigurationError(msg)
|
|
445
|
+
scored: list[tuple[float, SearchItem]] = []
|
|
446
|
+
seen_paths: set[str] = set()
|
|
447
|
+
for score, item in self._search_fts(query, scope_key=scope_key, limit=limit):
|
|
448
|
+
if item.path in seen_paths:
|
|
449
|
+
continue
|
|
450
|
+
scored.append((score + 1000, item))
|
|
451
|
+
seen_paths.add(item.path)
|
|
452
|
+
for score, item in self._search_relationships(query, scope_key=scope_key, limit=limit):
|
|
453
|
+
if item.path in seen_paths:
|
|
454
|
+
continue
|
|
455
|
+
scored.append((score, item))
|
|
456
|
+
seen_paths.add(item.path)
|
|
457
|
+
scored.sort(key=lambda item: (-item[0], item[1].path, item[1].title))
|
|
458
|
+
items = [item for _score, item in scored]
|
|
459
|
+
return SearchResult(items=items[:limit], truncated=len(items) > limit)
|
|
460
|
+
|
|
461
|
+
@_atomic
|
|
462
|
+
def add_node(self, label: str, node_id: str, *, properties: Properties | None = None, scope_key: str | None = None) -> None:
|
|
463
|
+
"""Add or update a node."""
|
|
464
|
+
validate_identifier(label, field="label")
|
|
465
|
+
validate_node_id(node_id)
|
|
466
|
+
self._ensure_node_table(label)
|
|
467
|
+
existing = self.get_node(label, node_id, scope_key=scope_key)
|
|
468
|
+
if existing is not None and properties is None:
|
|
469
|
+
return
|
|
470
|
+
merged = dict(existing.properties) if existing else {}
|
|
471
|
+
merged.update(_scoped_properties(properties, scope_key))
|
|
472
|
+
if existing and "created_at" in existing.properties:
|
|
473
|
+
merged["created_at"] = existing.properties["created_at"]
|
|
474
|
+
merged["updated_at"] = utc_now()
|
|
475
|
+
props = validate_properties(merged)
|
|
476
|
+
search_text = node_search_text(label, node_id, props)
|
|
477
|
+
self._query(
|
|
478
|
+
f"""
|
|
479
|
+
MERGE (n:{label} {{pk: $pk}})
|
|
480
|
+
SET n.id = $id,
|
|
481
|
+
n.type = "entity",
|
|
482
|
+
n.search_text = $search_text,
|
|
483
|
+
n.properties = $properties,
|
|
484
|
+
n.scope_key = $scope_key
|
|
485
|
+
""",
|
|
486
|
+
{
|
|
487
|
+
"pk": _node_pk(label, node_id, scope_key),
|
|
488
|
+
"id": node_id,
|
|
489
|
+
"search_text": search_text,
|
|
490
|
+
"properties": json.dumps(props, sort_keys=True),
|
|
491
|
+
"scope_key": scope_key,
|
|
492
|
+
},
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
@_atomic
|
|
496
|
+
def add_edge(
|
|
497
|
+
self,
|
|
498
|
+
source_label: str,
|
|
499
|
+
source_id: str,
|
|
500
|
+
relationship: str,
|
|
501
|
+
target_label: str,
|
|
502
|
+
target_id: str,
|
|
503
|
+
*,
|
|
504
|
+
properties: Properties | None = None,
|
|
505
|
+
scope_key: str | None = None,
|
|
506
|
+
) -> None:
|
|
507
|
+
"""Add or update an edge."""
|
|
508
|
+
validate_identifier(source_label, field="source_label")
|
|
509
|
+
validate_identifier(target_label, field="target_label")
|
|
510
|
+
validate_identifier(relationship, field="relationship")
|
|
511
|
+
validate_node_id(source_id)
|
|
512
|
+
validate_node_id(target_id)
|
|
513
|
+
props = _scoped_properties(properties, scope_key)
|
|
514
|
+
self.add_node(source_label, source_id, scope_key=scope_key)
|
|
515
|
+
self.add_node(target_label, target_id, scope_key=scope_key)
|
|
516
|
+
self._ensure_rel_table(relationship, source_label, target_label)
|
|
517
|
+
rows = self._query(
|
|
518
|
+
f"MATCH (source:{source_label} {{pk: $source_pk}})-[rel:{relationship}]->(target:{target_label} {{pk: $target_pk}}) RETURN rel",
|
|
519
|
+
{"source_pk": _node_pk(source_label, source_id, scope_key), "target_pk": _node_pk(target_label, target_id, scope_key)},
|
|
520
|
+
)
|
|
521
|
+
if rows:
|
|
522
|
+
existing_properties = _decode_properties(rows[0]["rel"])
|
|
523
|
+
props = validate_properties({**existing_properties, **props})
|
|
524
|
+
if "created_at" in existing_properties:
|
|
525
|
+
props["created_at"] = existing_properties["created_at"]
|
|
526
|
+
props["updated_at"] = utc_now()
|
|
527
|
+
self._query(
|
|
528
|
+
f"""
|
|
529
|
+
MATCH (source:{source_label} {{pk: $source_pk}}),
|
|
530
|
+
(target:{target_label} {{pk: $target_pk}})
|
|
531
|
+
MERGE (source)-[rel:{relationship}]->(target)
|
|
532
|
+
SET rel.properties = $properties,
|
|
533
|
+
rel.scope_key = $scope_key
|
|
534
|
+
""",
|
|
535
|
+
{
|
|
536
|
+
"source_pk": _node_pk(source_label, source_id, scope_key),
|
|
537
|
+
"target_pk": _node_pk(target_label, target_id, scope_key),
|
|
538
|
+
"properties": json.dumps(props, sort_keys=True),
|
|
539
|
+
"scope_key": scope_key,
|
|
540
|
+
},
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
@_atomic
|
|
544
|
+
def add_graph_documents(self, documents: Sequence[Any], *, scope_key: str | None = None) -> None:
|
|
545
|
+
"""Add graph documents through validated scoped writes."""
|
|
546
|
+
for document in documents:
|
|
547
|
+
nodes = getattr(document, "nodes", None)
|
|
548
|
+
relationships = getattr(document, "relationships", None)
|
|
549
|
+
if (
|
|
550
|
+
not isinstance(nodes, Sequence)
|
|
551
|
+
or isinstance(nodes, str | bytes)
|
|
552
|
+
or not isinstance(relationships, Sequence)
|
|
553
|
+
or isinstance(relationships, str | bytes)
|
|
554
|
+
):
|
|
555
|
+
msg = "documents must contain LangChain GraphDocument-like objects."
|
|
556
|
+
raise GraphMemoryValidationError(msg)
|
|
557
|
+
for node in nodes:
|
|
558
|
+
label, node_id, properties = _document_node(node, scope_key)
|
|
559
|
+
self.add_node(label, node_id, properties=properties, scope_key=scope_key)
|
|
560
|
+
for relationship in relationships:
|
|
561
|
+
source_label, source_id, _ = _document_node(getattr(relationship, "source", None), scope_key)
|
|
562
|
+
target_label, target_id, _ = _document_node(getattr(relationship, "target", None), scope_key)
|
|
563
|
+
self.add_edge(
|
|
564
|
+
source_label,
|
|
565
|
+
source_id,
|
|
566
|
+
getattr(relationship, "type", None),
|
|
567
|
+
target_label,
|
|
568
|
+
target_id,
|
|
569
|
+
properties=_with_scope(getattr(relationship, "properties", None), scope_key),
|
|
570
|
+
scope_key=scope_key,
|
|
571
|
+
)
|
|
572
|
+
|
|
573
|
+
def _get_immediate_edges(self, label: str, node_id: str, *, scope_key: str | None, limit: int) -> list[GraphEdge]:
|
|
574
|
+
params = {"pk": _node_pk(label, node_id, scope_key)}
|
|
575
|
+
rows = self._query(
|
|
576
|
+
f"""
|
|
577
|
+
MATCH (n:{label} {{pk: $pk}})-[r]->(m)
|
|
578
|
+
RETURN n, r, m
|
|
579
|
+
LIMIT {int(limit)}
|
|
580
|
+
""",
|
|
581
|
+
params,
|
|
582
|
+
)
|
|
583
|
+
incoming_rows = self._query(
|
|
584
|
+
f"""
|
|
585
|
+
MATCH (m)-[r]->(n:{label} {{pk: $pk}})
|
|
586
|
+
RETURN m, r, n
|
|
587
|
+
LIMIT {int(limit)}
|
|
588
|
+
""",
|
|
589
|
+
params,
|
|
590
|
+
)
|
|
591
|
+
edges: list[GraphEdge] = []
|
|
592
|
+
for row in rows:
|
|
593
|
+
edge = _coerce_edge(row, source_key="n", target_key="m")
|
|
594
|
+
if edge is not None:
|
|
595
|
+
edges.append(edge)
|
|
596
|
+
for row in incoming_rows:
|
|
597
|
+
edge = _coerce_edge(row, source_key="m", target_key="n")
|
|
598
|
+
if edge is not None:
|
|
599
|
+
edges.append(edge)
|
|
600
|
+
return edges
|
|
601
|
+
|
|
602
|
+
def _search_fts(self, query: str, *, scope_key: str | None, limit: int) -> list[tuple[float, SearchItem]]:
|
|
603
|
+
scored: list[tuple[float, SearchItem]] = []
|
|
604
|
+
for label in self._labels():
|
|
605
|
+
self._ensure_fts_index(label)
|
|
606
|
+
scope_where, scope_params = _scope_where("node", scope_key)
|
|
607
|
+
try:
|
|
608
|
+
rows = self._query(
|
|
609
|
+
f"""
|
|
610
|
+
CALL QUERY_FTS_INDEX('{label}', 'graph_memory_fts', $query)
|
|
611
|
+
WHERE {scope_where}
|
|
612
|
+
RETURN node, score
|
|
613
|
+
ORDER BY score DESC
|
|
614
|
+
LIMIT {int(limit) + 1}
|
|
615
|
+
""",
|
|
616
|
+
{"query": query, **scope_params},
|
|
617
|
+
)
|
|
618
|
+
except GraphMemoryConfigurationError as exc:
|
|
619
|
+
msg = f"LadybugDB full-text search failed for label {label!r}. Ensure the LadybugDB fts extension is available."
|
|
620
|
+
raise GraphMemoryConfigurationError(msg) from exc
|
|
621
|
+
for row in rows:
|
|
622
|
+
node = _coerce_node(row.get("node"), default_label=label, default_id="")
|
|
623
|
+
if not node.id:
|
|
624
|
+
continue
|
|
625
|
+
score = float(row.get("score", 0.0) or 0.0)
|
|
626
|
+
scored.append(
|
|
627
|
+
(
|
|
628
|
+
score,
|
|
629
|
+
SearchItem(
|
|
630
|
+
path=node_path(node.label, node.id),
|
|
631
|
+
title=f"{node.label}: {node.id}",
|
|
632
|
+
text=_summarize_properties(node.properties),
|
|
633
|
+
),
|
|
634
|
+
)
|
|
635
|
+
)
|
|
636
|
+
return scored
|
|
637
|
+
|
|
638
|
+
def _search_relationships(self, query: str, *, scope_key: str | None, limit: int) -> list[tuple[float, SearchItem]]:
|
|
639
|
+
scored: list[tuple[float, SearchItem]] = []
|
|
640
|
+
for relationship in self._relationships():
|
|
641
|
+
score = lexical_search_score(query, relationship.replace("_", " "))
|
|
642
|
+
if score <= 0:
|
|
643
|
+
continue
|
|
644
|
+
scope_where, params = _scope_where("r", scope_key)
|
|
645
|
+
rows = self._query(
|
|
646
|
+
f"""
|
|
647
|
+
MATCH (source)-[r:{relationship}]->(target)
|
|
648
|
+
WHERE {scope_where}
|
|
649
|
+
RETURN source, r, target
|
|
650
|
+
LIMIT {int(limit) + 1}
|
|
651
|
+
""",
|
|
652
|
+
params,
|
|
653
|
+
)
|
|
654
|
+
for row in rows:
|
|
655
|
+
edge = _coerce_edge(row, source_key="source", target_key="target")
|
|
656
|
+
if edge is None:
|
|
657
|
+
continue
|
|
658
|
+
item = SearchItem(
|
|
659
|
+
path=node_path(edge.source_label, edge.source_id),
|
|
660
|
+
title=f"{edge.source_id} {edge.relationship} {edge.target_id}",
|
|
661
|
+
text=_summarize_properties(edge.properties),
|
|
662
|
+
)
|
|
663
|
+
scored.append((float(score), item))
|
|
664
|
+
return scored
|
|
665
|
+
|
|
666
|
+
def _ensure_fts_index(self, label: str) -> bool:
|
|
667
|
+
if label in self._fts_ready_labels:
|
|
668
|
+
return True
|
|
669
|
+
validate_identifier(label, field="label")
|
|
670
|
+
try:
|
|
671
|
+
self._query("LOAD fts;", {})
|
|
672
|
+
except GraphMemoryConfigurationError as exc:
|
|
673
|
+
message = str(exc).casefold()
|
|
674
|
+
if "already" not in message and "loaded" not in message:
|
|
675
|
+
msg = (
|
|
676
|
+
"LadybugDB full-text search requires the `fts` extension, but loading it failed. "
|
|
677
|
+
"Provision it with `INSTALL fts;` using LadybugDB before running the agent; runtime search does not download extensions."
|
|
678
|
+
)
|
|
679
|
+
raise GraphMemoryConfigurationError(msg) from exc
|
|
680
|
+
try:
|
|
681
|
+
self._query(f"CALL CREATE_FTS_INDEX('{label}', 'graph_memory_fts', ['id', 'type', 'search_text', 'properties']);", {})
|
|
682
|
+
except GraphMemoryConfigurationError as exc:
|
|
683
|
+
message = str(exc).casefold()
|
|
684
|
+
if "already" not in message and "exist" not in message:
|
|
685
|
+
msg = f"LadybugDB full-text search index creation failed for label {label!r}."
|
|
686
|
+
raise GraphMemoryConfigurationError(msg) from exc
|
|
687
|
+
self._fts_ready_labels.add(label)
|
|
688
|
+
return True
|
|
689
|
+
|
|
690
|
+
def _labels(self) -> list[str]:
|
|
691
|
+
rows = self._query("CALL SHOW_TABLES() RETURN *;", {})
|
|
692
|
+
return sorted(str(row.get("name")) for row in rows if row.get("type") == "NODE")
|
|
693
|
+
|
|
694
|
+
def _relationships(self) -> list[str]:
|
|
695
|
+
rows = self._query("CALL SHOW_TABLES() RETURN *;", {})
|
|
696
|
+
return sorted(str(row.get("name")) for row in rows if row.get("type") == "REL")
|
|
697
|
+
|
|
698
|
+
def _refresh_schema(self) -> None:
|
|
699
|
+
self.graph.refresh_schema()
|
|
700
|
+
|
|
701
|
+
def _query(self, query: str, params: dict[str, Any]) -> list[dict[str, Any]]:
|
|
702
|
+
self._require_open()
|
|
703
|
+
if self._transaction_depth and self._rollback_only and query.strip().upper() != "ROLLBACK;":
|
|
704
|
+
msg = "Graph transaction already failed; it must roll back."
|
|
705
|
+
raise GraphMemoryConfigurationError(msg)
|
|
706
|
+
try:
|
|
707
|
+
return cast("list[dict[str, Any]]", self.graph.query(query, params))
|
|
708
|
+
except Exception as exc: # noqa: BLE001
|
|
709
|
+
if self._transaction_depth:
|
|
710
|
+
self._rollback_only = True
|
|
711
|
+
msg = f"LadybugDB graph query failed: {exc}"
|
|
712
|
+
raise GraphMemoryConfigurationError(msg) from exc
|
|
713
|
+
|
|
714
|
+
def _ensure_node_table(self, label: str) -> None:
|
|
715
|
+
self._reject_table_collision(label, "NODE")
|
|
716
|
+
self._query(
|
|
717
|
+
f"""
|
|
718
|
+
CREATE NODE TABLE IF NOT EXISTS {label} (
|
|
719
|
+
pk STRING,
|
|
720
|
+
id STRING,
|
|
721
|
+
type STRING,
|
|
722
|
+
search_text STRING,
|
|
723
|
+
properties STRING,
|
|
724
|
+
scope_key STRING,
|
|
725
|
+
PRIMARY KEY(pk)
|
|
726
|
+
);
|
|
727
|
+
""",
|
|
728
|
+
{},
|
|
729
|
+
)
|
|
730
|
+
|
|
731
|
+
def _ensure_rel_table(self, relationship: str, source_label: str, target_label: str) -> None:
|
|
732
|
+
self._reject_table_collision(relationship, "REL")
|
|
733
|
+
self._query(
|
|
734
|
+
f"""
|
|
735
|
+
CREATE REL TABLE IF NOT EXISTS {relationship} (
|
|
736
|
+
FROM {source_label} TO {target_label},
|
|
737
|
+
properties STRING,
|
|
738
|
+
scope_key STRING
|
|
739
|
+
);
|
|
740
|
+
""",
|
|
741
|
+
{},
|
|
742
|
+
)
|
|
743
|
+
connections = self._query(f"CALL SHOW_CONNECTION('{relationship}') RETURN *;", {})
|
|
744
|
+
if any(row["source table name"] == source_label and row["destination table name"] == target_label for row in connections):
|
|
745
|
+
return
|
|
746
|
+
self._query(f"ALTER TABLE {relationship} ADD FROM {source_label} TO {target_label};", {})
|
|
747
|
+
|
|
748
|
+
def _reject_table_collision(self, name: str, expected_type: str) -> None:
|
|
749
|
+
for row in self._query("CALL SHOW_TABLES() RETURN *;", {}):
|
|
750
|
+
if str(row["name"]).casefold() == name.casefold() and (row["name"] != name or row["type"] != expected_type):
|
|
751
|
+
msg = f"Graph table {name!r} conflicts with existing {row['type'].lower()} table {row['name']!r}."
|
|
752
|
+
raise GraphMemoryValidationError(msg)
|
|
753
|
+
|
|
754
|
+
def _label_has_nodes(self, label: str, *, scope_key: str | None) -> bool:
|
|
755
|
+
scope_where, params = _scope_where("n", scope_key)
|
|
756
|
+
rows = self._query(
|
|
757
|
+
f"""
|
|
758
|
+
MATCH (n:{label})
|
|
759
|
+
WHERE {scope_where}
|
|
760
|
+
RETURN n.id AS id
|
|
761
|
+
LIMIT 1
|
|
762
|
+
""",
|
|
763
|
+
params,
|
|
764
|
+
)
|
|
765
|
+
return bool(rows)
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
def _coerce_node(value: Any, *, default_label: str, default_id: str) -> GraphNode:
|
|
769
|
+
data = value if isinstance(value, dict) else {}
|
|
770
|
+
label = str(data.get("_LABEL", data.get("label", default_label)))
|
|
771
|
+
node_id = str(data.get("id", default_id))
|
|
772
|
+
properties = _decode_properties(data)
|
|
773
|
+
for key, item in data.items():
|
|
774
|
+
if key not in {"_ID", "_LABEL", "id", "pk", "properties", "search_text", "type"} and item is not None:
|
|
775
|
+
properties.setdefault(key, item)
|
|
776
|
+
return GraphNode(label=label, id=node_id, properties=validate_properties(properties))
|
|
777
|
+
|
|
778
|
+
|
|
779
|
+
def _coerce_edge(row: dict[str, Any], *, source_key: str, target_key: str) -> GraphEdge | None:
|
|
780
|
+
raw_edge = row.get("r")
|
|
781
|
+
if not isinstance(raw_edge, dict):
|
|
782
|
+
return None
|
|
783
|
+
source = _coerce_node(row.get(source_key), default_label="Node", default_id="")
|
|
784
|
+
target = _coerce_node(row.get(target_key), default_label="Node", default_id="")
|
|
785
|
+
relationship = str(raw_edge.get("_LABEL", raw_edge.get("label", raw_edge.get("type", "RELATED_TO"))))
|
|
786
|
+
properties = _decode_properties(raw_edge)
|
|
787
|
+
for key, item in raw_edge.items():
|
|
788
|
+
if not key.startswith("_") and key not in {"label", "type", "properties"} and item is not None:
|
|
789
|
+
properties.setdefault(key, item)
|
|
790
|
+
return GraphEdge(
|
|
791
|
+
source_label=source.label,
|
|
792
|
+
source_id=source.id,
|
|
793
|
+
relationship=relationship,
|
|
794
|
+
target_label=target.label,
|
|
795
|
+
target_id=target.id,
|
|
796
|
+
properties=validate_properties(properties),
|
|
797
|
+
)
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
def _decode_properties(data: dict[str, Any]) -> dict[str, Any]:
|
|
801
|
+
raw = data.get("properties")
|
|
802
|
+
if isinstance(raw, str):
|
|
803
|
+
try:
|
|
804
|
+
decoded = json.loads(raw)
|
|
805
|
+
except json.JSONDecodeError:
|
|
806
|
+
return {"properties": raw}
|
|
807
|
+
return decoded if isinstance(decoded, dict) else {"properties": decoded}
|
|
808
|
+
if isinstance(raw, dict):
|
|
809
|
+
return raw
|
|
810
|
+
return {}
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
def _summarize_properties(properties: dict[str, Any]) -> str:
|
|
814
|
+
public = {key: value for key, value in properties.items() if key not in {"pk", "scope_key", "search_text"}}
|
|
815
|
+
if not public:
|
|
816
|
+
return ""
|
|
817
|
+
return json.dumps(public, sort_keys=True)
|
|
818
|
+
|
|
819
|
+
|
|
820
|
+
def _with_scope(properties: dict[str, Any] | None, scope_key: str | None) -> Properties:
|
|
821
|
+
return _scoped_properties(properties, scope_key)
|
|
822
|
+
|
|
823
|
+
|
|
824
|
+
def _document_node(value: Any, scope_key: str | None) -> tuple[str, str, Properties]:
|
|
825
|
+
label = validate_identifier(getattr(value, "type", None), field="label")
|
|
826
|
+
node_id = validate_node_id(getattr(value, "id", None))
|
|
827
|
+
return label, node_id, _with_scope(getattr(value, "properties", None), scope_key)
|
|
828
|
+
|
|
829
|
+
|
|
830
|
+
def _scoped_properties(properties: dict[str, Any] | None, scope_key: str | None) -> Properties:
|
|
831
|
+
scoped = validate_properties(properties)
|
|
832
|
+
if "scope_key" in scoped and scoped["scope_key"] != scope_key:
|
|
833
|
+
msg = "scope_key cannot differ from the active namespace."
|
|
834
|
+
raise GraphMemoryValidationError(msg)
|
|
835
|
+
if scope_key is not None:
|
|
836
|
+
scoped["scope_key"] = scope_key
|
|
837
|
+
return scoped
|
|
838
|
+
|
|
839
|
+
|
|
840
|
+
def _node_pk(label: str, node_id: str, scope_key: str | None) -> str:
|
|
841
|
+
return json.dumps([scope_key, label, node_id], separators=(",", ":"))
|
|
842
|
+
|
|
843
|
+
|
|
844
|
+
def _scope_where(alias: str, scope_key: str | None) -> tuple[str, dict[str, Any]]:
|
|
845
|
+
if scope_key is None:
|
|
846
|
+
return f"{alias}.scope_key IS NULL", {}
|
|
847
|
+
return f"{alias}.scope_key = $scope_key", {"scope_key": scope_key}
|