memgraph-toolbox 0.2.0__tar.gz → 0.2.1__tar.gz

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 (32) hide show
  1. {memgraph_toolbox-0.2.0 → memgraph_toolbox-0.2.1}/.gitignore +3 -6
  2. {memgraph_toolbox-0.2.0 → memgraph_toolbox-0.2.1}/PKG-INFO +2 -2
  3. {memgraph_toolbox-0.2.0 → memgraph_toolbox-0.2.1}/pyproject.toml +1 -1
  4. {memgraph_toolbox-0.2.0 → memgraph_toolbox-0.2.1}/src/memgraph_toolbox/api/memgraph.py +42 -32
  5. {memgraph_toolbox-0.2.0 → memgraph_toolbox-0.2.1}/src/memgraph_toolbox/api/tool.py +4 -2
  6. {memgraph_toolbox-0.2.0 → memgraph_toolbox-0.2.1}/src/memgraph_toolbox/client/mcp_prompt.py +10 -14
  7. {memgraph_toolbox-0.2.0 → memgraph_toolbox-0.2.1}/src/memgraph_toolbox/evals/coherence.py +8 -3
  8. {memgraph_toolbox-0.2.0 → memgraph_toolbox-0.2.1}/src/memgraph_toolbox/memgraph_toolbox.py +0 -2
  9. {memgraph_toolbox-0.2.0 → memgraph_toolbox-0.2.1}/src/memgraph_toolbox/tests/test_async_memgraph.py +2 -2
  10. {memgraph_toolbox-0.2.0 → memgraph_toolbox-0.2.1}/src/memgraph_toolbox/tests/test_toolbox.py +1 -5
  11. {memgraph_toolbox-0.2.0 → memgraph_toolbox-0.2.1}/src/memgraph_toolbox/tests/test_tools.py +66 -1
  12. {memgraph_toolbox-0.2.0 → memgraph_toolbox-0.2.1}/src/memgraph_toolbox/tools/schema.py +3 -3
  13. {memgraph_toolbox-0.2.0 → memgraph_toolbox-0.2.1}/src/memgraph_toolbox/utils/embedding.py +1 -8
  14. memgraph_toolbox-0.2.1/src/memgraph_toolbox/utils/serialization.py +160 -0
  15. memgraph_toolbox-0.2.0/src/memgraph_toolbox/utils/serialization.py +0 -64
  16. {memgraph_toolbox-0.2.0 → memgraph_toolbox-0.2.1}/LICENSE +0 -0
  17. {memgraph_toolbox-0.2.0 → memgraph_toolbox-0.2.1}/README.md +0 -0
  18. {memgraph_toolbox-0.2.0 → memgraph_toolbox-0.2.1}/src/memgraph_toolbox/__init__.py +0 -0
  19. {memgraph_toolbox-0.2.0 → memgraph_toolbox-0.2.1}/src/memgraph_toolbox/api/__init__.py +0 -0
  20. {memgraph_toolbox-0.2.0 → memgraph_toolbox-0.2.1}/src/memgraph_toolbox/api/toolbox.py +0 -0
  21. {memgraph_toolbox-0.2.0 → memgraph_toolbox-0.2.1}/src/memgraph_toolbox/client/__init__.py +0 -0
  22. {memgraph_toolbox-0.2.0 → memgraph_toolbox-0.2.1}/src/memgraph_toolbox/evals/__init__.py +0 -0
  23. {memgraph_toolbox-0.2.0 → memgraph_toolbox-0.2.1}/src/memgraph_toolbox/py.typed +0 -0
  24. {memgraph_toolbox-0.2.0 → memgraph_toolbox-0.2.1}/src/memgraph_toolbox/tests/__init__.py +0 -0
  25. {memgraph_toolbox-0.2.0 → memgraph_toolbox-0.2.1}/src/memgraph_toolbox/tests/test_embedding.py +0 -0
  26. {memgraph_toolbox-0.2.0 → memgraph_toolbox-0.2.1}/src/memgraph_toolbox/tests/test_eval_coherence.py +0 -0
  27. {memgraph_toolbox-0.2.0 → memgraph_toolbox-0.2.1}/src/memgraph_toolbox/tests/test_mcp_prompt.py +0 -0
  28. {memgraph_toolbox-0.2.0 → memgraph_toolbox-0.2.1}/src/memgraph_toolbox/tests/test_memgraph_config.py +0 -0
  29. {memgraph_toolbox-0.2.0 → memgraph_toolbox-0.2.1}/src/memgraph_toolbox/tools/__init__.py +0 -0
  30. {memgraph_toolbox-0.2.0 → memgraph_toolbox-0.2.1}/src/memgraph_toolbox/tools/cypher.py +0 -0
  31. {memgraph_toolbox-0.2.0 → memgraph_toolbox-0.2.1}/src/memgraph_toolbox/utils/logger.py +0 -0
  32. {memgraph_toolbox-0.2.0 → memgraph_toolbox-0.2.1}/uv.lock +0 -0
@@ -181,12 +181,9 @@ cython_debug/
181
181
  # Local agent hook configuration
182
182
  /.codex/
183
183
  /.claude/settings.local.json
184
- /AGENTS.md
185
- /docs/agents/
186
- /context-graph/CONTEXT-MAP.md
187
- /context-graph/*/CONTEXT.md
188
- /context-graph/docs/adr/
189
- /context-graph/*/docs/adr/
190
184
 
191
185
  # Project specific files
192
186
  /enterprise-context/sic-agent/sic-scrapper/output/*
187
+
188
+ # deepeval writes run caches and telemetry here; build artifacts, not source.
189
+ .deepeval/
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: memgraph-toolbox
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Memgraph toolbox library for Memgraph AI tools and utilities
5
5
  Project-URL: Homepage, https://github.com/memgraph/ai-toolkit
6
6
  Project-URL: Source, https://github.com/memgraph/ai-toolkit
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "memgraph-toolbox"
3
- version = "0.2.0"
3
+ version = "0.2.1"
4
4
  description = "Memgraph toolbox library for Memgraph AI tools and utilities"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -1,10 +1,10 @@
1
1
  import os
2
2
  from collections.abc import Mapping
3
- from typing import Any
3
+ from typing import Any, cast
4
4
 
5
5
  from neo4j import AsyncGraphDatabase, GraphDatabase
6
6
 
7
- from ..utils.serialization import serialize_record_data
7
+ from ..utils.serialization import serialize_records
8
8
 
9
9
  MEMGRAPH_ENV_DEFAULTS = {
10
10
  "MEMGRAPH_URL": "bolt://localhost:7687",
@@ -87,10 +87,10 @@ class Memgraph:
87
87
 
88
88
  def __init__(
89
89
  self,
90
- url: str = None,
91
- username: str = None,
92
- password: str = None,
93
- database: str = None,
90
+ url: str | None = None,
91
+ username: str | None = None,
92
+ password: str | None = None,
93
+ database: str | None = None,
94
94
  driver_config: dict | None = None,
95
95
  user_agent: str | None = None,
96
96
  ):
@@ -140,37 +140,43 @@ class Memgraph:
140
140
  f"Could not connect to Memgraph database. Authentication failed for user '{username}'"
141
141
  ) from e
142
142
 
143
- def query(self, query: str, params: dict = None) -> list[dict[str, Any]]:
143
+ def query(self, query: str, params: dict | None = None) -> list[dict[str, Any]]:
144
144
  """
145
- Execute a Cypher query and return results as a list of dictionaries.
145
+ Execute a Cypher query and return type-preserving result rows.
146
+
147
+ Each value keeps its type: nodes, relationships and paths become
148
+ ``_type``-tagged objects (id, labels/type, endpoints, properties);
149
+ primitives, lists, maps, temporals and points keep their shape.
146
150
 
147
151
  Args:
148
152
  query: The Cypher query to execute
153
+ params: Optional query parameters
149
154
 
150
155
  Returns:
151
- List of dictionaries containing query results
156
+ List of dictionaries, one per result row
152
157
  """
153
158
  from neo4j.exceptions import Neo4jError
154
159
 
155
160
  if params is None:
156
161
  params = {}
157
162
  try:
158
- data, _, _ = self.driver.execute_query(
159
- query,
163
+ # neo4j's driver stubs require query_: LiteralString | Query, a hint aimed at
164
+ # discouraging unparameterized query construction. This is a generic passthrough
165
+ # executor that legitimately accepts caller-built Cypher text (injection is
166
+ # already guarded by `params`), which a `str` can never satisfy statically.
167
+ records, _, _ = self.driver.execute_query(
168
+ cast("Any", query),
160
169
  parameters_=params,
161
170
  database_=self.database,
162
171
  )
163
- json_data = [serialize_record_data(r.data()) for r in data]
164
- return json_data
172
+ return serialize_records(records)
165
173
  except Neo4jError as e:
166
174
  if not _is_implicit_transaction_fallback_error(e):
167
175
  raise
168
176
 
169
177
  # fallback to allow implicit transactions
170
178
  with self.driver.session(database=self.database) as session:
171
- data = session.run(query, params)
172
- json_data = [serialize_record_data(r.data()) for r in data]
173
- return json_data
179
+ return serialize_records(session.run(cast("Any", query), params))
174
180
 
175
181
  def close(self) -> None:
176
182
  """
@@ -191,10 +197,10 @@ class AsyncMemgraph:
191
197
 
192
198
  def __init__(
193
199
  self,
194
- url: str = None,
195
- username: str = None,
196
- password: str = None,
197
- database: str = None,
200
+ url: str | None = None,
201
+ username: str | None = None,
202
+ password: str | None = None,
203
+ database: str | None = None,
198
204
  driver_config: dict | None = None,
199
205
  user_agent: str | None = None,
200
206
  ):
@@ -236,41 +242,45 @@ class AsyncMemgraph:
236
242
  """
237
243
  await self.driver.verify_connectivity()
238
244
 
239
- async def query(self, query: str, params: dict = None) -> list[dict[str, Any]]:
245
+ async def query(self, query: str, params: dict | None = None) -> list[dict[str, Any]]:
240
246
  """
241
- Execute a Cypher query and return results as a list of dictionaries.
247
+ Execute a Cypher query and return type-preserving result rows.
242
248
 
243
- Mirrors :meth:`Memgraph.query`, including the fallback to an implicit
244
- transaction for statements that cannot run inside a managed transaction.
249
+ Async mirror of :meth:`Memgraph.query`, including the fallback to an
250
+ implicit transaction for statements that cannot run inside a managed
251
+ transaction.
245
252
 
246
253
  Args:
247
254
  query: The Cypher query to execute
248
255
  params: Optional query parameters
249
256
 
250
257
  Returns:
251
- List of dictionaries containing query results
258
+ List of dictionaries, one per result row
252
259
  """
253
260
  from neo4j.exceptions import Neo4jError
254
261
 
255
262
  if params is None:
256
263
  params = {}
257
264
  try:
258
- data, _, _ = await self.driver.execute_query(
259
- query,
265
+ # See the matching comment in Memgraph.query: query_ is typed LiteralString |
266
+ # Query in the driver stubs, but this is a generic passthrough executor that
267
+ # legitimately accepts caller-built Cypher text -- a `str` can never satisfy
268
+ # LiteralString statically, regardless of how it was validated at runtime.
269
+ records, _, _ = await self.driver.execute_query(
270
+ cast("Any", query),
260
271
  parameters_=params,
261
272
  database_=self.database,
262
273
  )
263
- json_data = [serialize_record_data(r.data()) for r in data]
264
- return json_data
274
+ return serialize_records(records)
265
275
  except Neo4jError as e:
266
276
  if not _is_implicit_transaction_fallback_error(e):
267
277
  raise
268
278
 
269
279
  # fallback to allow implicit transactions
270
280
  async with self.driver.session(database=self.database) as session:
271
- result = await session.run(query, params)
272
- json_data = [serialize_record_data(r.data()) async for r in result]
273
- return json_data
281
+ result = await session.run(cast("Any", query), params)
282
+ records = [record async for record in result]
283
+ return serialize_records(records)
274
284
 
275
285
  async def close(self) -> None:
276
286
  """
@@ -17,7 +17,7 @@ class BaseTool(ABC):
17
17
  self.input_schema = input_schema
18
18
 
19
19
  @abstractmethod
20
- def call(self, arguments: dict[str, Any]) -> list[Any]:
20
+ def call(self, arguments: dict[str, Any]) -> list[Any] | dict[str, Any]:
21
21
  """
22
22
  Execute the tool with the provided arguments.
23
23
 
@@ -25,7 +25,9 @@ class BaseTool(ABC):
25
25
  arguments (dict): A dictionary of arguments as defined by the input schema.
26
26
 
27
27
  Returns:
28
- List containing one or more output values.
28
+ Either a list containing one or more output values, or -- for tools whose
29
+ "found" result is naturally a single object (e.g. schema lookups) -- a bare
30
+ dict.
29
31
  """
30
32
  pass
31
33
 
@@ -3,6 +3,7 @@ import json
3
3
  import logging
4
4
  import os
5
5
  import sys
6
+ from typing import cast
6
7
 
7
8
  try:
8
9
  from litellm import acompletion, experimental_mcp_client
@@ -19,9 +20,9 @@ logger = logging.getLogger(__name__)
19
20
  async def prompt_with_tools(
20
21
  prompt: str,
21
22
  tool_selection_model_name: str = "openai/gpt-4o",
22
- tool_selection_system_messages: list[dict[str, str]] = None,
23
+ tool_selection_system_messages: list[dict[str, str]] | None = None,
23
24
  response_model_name: str = "openai/gpt-4o",
24
- response_system_messages: list[dict[str, str]] = None,
25
+ response_system_messages: list[dict[str, str]] | None = None,
25
26
  mcp_server_params: StdioServerParameters | None = None,
26
27
  ):
27
28
  """
@@ -29,7 +30,6 @@ async def prompt_with_tools(
29
30
  First there is a tool selection phase where the LLM is asked to select the tools to use.
30
31
  Second there is a response phase where the LLM is asked to generate a response.
31
32
  """
32
- # Configure MCP server connection.
33
33
  if response_system_messages is None:
34
34
  response_system_messages = []
35
35
  if tool_selection_system_messages is None:
@@ -48,18 +48,17 @@ async def prompt_with_tools(
48
48
  ],
49
49
  env={
50
50
  "MEMGRAPH_URL": os.environ.get("MEMGRAPH_URL", "bolt://localhost:7687"),
51
- # NOTE: If you want to control where uv pulls the dependencies from inject UV_PYTHON or VIRTUAL_ENV.
51
+ # Set UV_PYTHON or VIRTUAL_ENV when the MCP subprocess must use a specific environment.
52
52
  },
53
53
  )
54
54
  else:
55
55
  server_params = mcp_server_params
56
56
 
57
- # Establish MCP server connection.
58
57
  async with stdio_client(server_params) as (read, write), ClientSession(read, write) as session:
59
58
  await session.initialize()
60
59
  # https://docs.litellm.ai/docs/mcp#litellm-python-sdk-mcp-bridge
61
60
  tools = await experimental_mcp_client.load_mcp_tools(
62
- # NOTE: format="openai" works with everything tested, "mcp" does NOT work.
61
+ # LiteLLM's OpenAI format is required for the tool-call shape consumed below.
63
62
  session=session,
64
63
  format="openai",
65
64
  )
@@ -69,7 +68,6 @@ async def prompt_with_tools(
69
68
  )
70
69
  logger.info(f"{len(tools)} MCP tools loaded")
71
70
 
72
- # Tool selection by an LLM.
73
71
  messages = [*tool_selection_system_messages, {"role": "user", "content": prompt}]
74
72
  resp = await acompletion(
75
73
  model=tool_selection_model_name,
@@ -77,7 +75,6 @@ async def prompt_with_tools(
77
75
  tools=tools,
78
76
  )
79
77
  msg = resp["choices"][0]["message"]
80
- # Tool calls by the MCP server.
81
78
  tool_calls = msg.get("tool_calls", [])
82
79
  if not tool_calls:
83
80
  logger.info("LLM Response: %s", msg.get("content", ""))
@@ -89,7 +86,7 @@ async def prompt_with_tools(
89
86
  tc["function"]["name"],
90
87
  tc["function"].get("description", "No description"),
91
88
  )
92
- # Ensure all tool call IDs are within OpenAI's 40-character limit
89
+ # OpenAI limits tool-call IDs to 40 characters.
93
90
  for tc in tool_calls:
94
91
  if len(tc["id"]) > 40:
95
92
  logger.warning(
@@ -113,10 +110,11 @@ async def prompt_with_tools(
113
110
  result = await session.call_tool(name=tc["function"]["name"], arguments=arguments)
114
111
  if hasattr(result, "content") and result.content:
115
112
  if isinstance(result.content, list):
116
- content_text = []
113
+ content_text: list[str] = []
117
114
  for content_item in result.content:
118
115
  if hasattr(content_item, "text"):
119
- content_text.append(content_item.text)
116
+ # Only TextContent exposes `.text`; other content blocks are serialized below.
117
+ content_text.append(cast("str", content_item.text))
120
118
  elif isinstance(content_item, str):
121
119
  content_text.append(content_item)
122
120
  else:
@@ -148,7 +146,6 @@ async def prompt_with_tools(
148
146
  tool_error,
149
147
  )
150
148
 
151
- # Generating the final response using LLM.
152
149
  messages = messages + response_system_messages
153
150
  final_resp = await acompletion(
154
151
  model=response_model_name,
@@ -177,7 +174,6 @@ async def __async_context():
177
174
 
178
175
 
179
176
  if __name__ == "__main__":
180
- # NOTE: Chaning how mcp-memgraph logs from here is not easy because the
181
- # server is started in another process.
177
+ # The MCP server runs in a separate process, so configure its client-side logger here.
182
178
  logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
183
179
  asyncio.run(__async_context())
@@ -110,8 +110,11 @@ class CoherenceEmbeddingsBasedMetric(BaseMetric):
110
110
  Returns:
111
111
  Array of embeddings with shape (n_sentences, embedding_dim)
112
112
  """
113
- # TODO(gitbuda): This should use the utils.embeddings because GPU could be used.
114
- return self.model.encode(sentences)
113
+ # Keep this local model call until the shared embedding utility supports this metric's API.
114
+ # convert_to_numpy is already the model's default; pinning it explicitly picks the
115
+ # np.ndarray-returning overload instead of leaving overload resolution to fall
116
+ # through to a Tensor-returning one.
117
+ return self.model.encode(sentences, convert_to_numpy=True)
115
118
 
116
119
  def _compute_cosine_similarity(self, embeddings: np.ndarray) -> list[float]:
117
120
  """Compute cosine similarity between consecutive embeddings.
@@ -221,7 +224,9 @@ class CoherenceEmbeddingsBasedMetric(BaseMetric):
221
224
  Returns:
222
225
  True if score meets threshold, False otherwise
223
226
  """
224
- return self.success
227
+ # self.success is bool | None (None until measure() has run); treat "never measured"
228
+ # as "not successful" rather than lying about the return type or leaking None.
229
+ return self.success or False
225
230
 
226
231
  @property
227
232
  def __name__(self) -> str:
@@ -1,7 +1,5 @@
1
1
  from .api.memgraph import Memgraph
2
2
  from .api.toolbox import BaseToolbox
3
-
4
- # Import all tool classes
5
3
  from .tools.cypher import CypherTool
6
4
  from .tools.schema import EnumSchemaTool, NodeSchemaTool, RelationshipSchemaTool, SearchSchemaTool
7
5
 
@@ -42,12 +42,12 @@ async def test_async_memgraph_query_live():
42
42
  """Live round-trip; skips cleanly when no server is reachable."""
43
43
  import neo4j
44
44
 
45
- client = AsyncMemgraph(url="bolt://localhost:7687", username="", password="")
45
+ client = AsyncMemgraph()
46
46
  try:
47
47
  await client.verify_connectivity()
48
48
  except (neo4j.exceptions.ServiceUnavailable, neo4j.exceptions.SessionExpired, OSError):
49
49
  await client.close()
50
- pytest.skip("No Memgraph server reachable at bolt://localhost:7687")
50
+ pytest.skip("No Memgraph server reachable at the configured MEMGRAPH_URL")
51
51
 
52
52
  try:
53
53
  result = await client.query("RETURN 1 AS value")
@@ -40,11 +40,7 @@ def test_base_toolbox():
40
40
  def test_memgraph_toolbox():
41
41
  """Test the Memgraph Toolbox."""
42
42
 
43
- url = "bolt://localhost:7687"
44
- user = ""
45
- password = ""
46
-
47
- memgraph_client = Memgraph(url=url, username=user, password=password)
43
+ memgraph_client = Memgraph()
48
44
 
49
45
  toolkit = MemgraphToolbox(db=memgraph_client)
50
46
 
@@ -14,7 +14,7 @@ from ..tools.schema import (
14
14
 
15
15
  @pytest.fixture()
16
16
  def db():
17
- client = Memgraph(url="bolt://localhost:7687", username="", password="")
17
+ client = Memgraph()
18
18
  yield client
19
19
  client.close()
20
20
 
@@ -72,6 +72,71 @@ def test_cypher_date_time_serialization(db):
72
72
  assert "PT2M2.33S" in record["test_duration"]
73
73
 
74
74
 
75
+ def test_query_tags_graph_entities(db, schema_graph):
76
+ """Nodes, relationships and paths are returned as _type-tagged objects."""
77
+ rows = db.query("MATCH p = (a:Person {name: 'Alice'})-[r:KNOWS]->(b) RETURN a, r, b, p")
78
+
79
+ assert len(rows) == 1
80
+ row = rows[0]
81
+
82
+ assert row["a"]["_type"] == "node"
83
+ assert "Person" in row["a"]["labels"]
84
+ assert row["a"]["properties"]["name"] == "Alice"
85
+ assert "id" in row["a"]
86
+
87
+ assert row["r"]["_type"] == "relationship"
88
+ assert row["r"]["type"] == "KNOWS"
89
+ assert row["r"]["start"] == row["a"]["id"]
90
+ assert row["r"]["end"] == row["b"]["id"]
91
+
92
+ assert row["p"]["_type"] == "path"
93
+ assert len(row["p"]["nodes"]) == 2
94
+ assert len(row["p"]["relationships"]) == 1
95
+
96
+
97
+ def test_query_mixed_row(db, schema_graph):
98
+ """A row can mix a typed node with a scalar aggregation in the same result."""
99
+ rows = db.query("MATCH (n:Person)-[e]->() RETURN n, count(e) AS out_deg")
100
+
101
+ assert len(rows) > 0
102
+ row = rows[0]
103
+ assert row["n"]["_type"] == "node"
104
+ assert isinstance(row["out_deg"], int)
105
+
106
+
107
+ def test_query_primitives_and_containers(db):
108
+ """Primitives, lists, maps and temporals pass through with their shape."""
109
+ rows = db.query("RETURN 1 AS i, 'x' AS s, [1, 2] AS lst, {k: 3} AS m, date('2024-01-15') AS d")
110
+
111
+ row = rows[0]
112
+ assert row["i"] == 1
113
+ assert row["s"] == "x"
114
+ assert row["lst"] == [1, 2]
115
+ assert row["m"] == {"k": 3}
116
+ assert row["d"] == "2024-01-15"
117
+
118
+
119
+ def test_query_point(db):
120
+ """A geo/point value is serialized to a tagged srid + coordinates record."""
121
+ rows = db.query("RETURN point({x: 1.5, y: 2.5}) AS p")
122
+
123
+ point = rows[0]["p"]
124
+ assert point["_type"] == "point"
125
+ assert "srid" in point
126
+ assert point["coordinates"] == [1.5, 2.5]
127
+
128
+
129
+ def test_query_temporals(db):
130
+ """Temporal types serialize to ISO strings, zoned datetimes keeping the offset."""
131
+ rows = db.query("RETURN datetime('2024-01-15T10:30:45+01:00') AS dt, duration('PT2M') AS dur")
132
+
133
+ row = rows[0]
134
+ assert "2024-01-15T10:30:45" in row["dt"]
135
+ assert "+01:00" in row["dt"]
136
+ assert isinstance(row["dur"], str)
137
+ assert "PT2M" in row["dur"]
138
+
139
+
75
140
  def test_get_node_schema_found(db, schema_graph):
76
141
  tool = NodeSchemaTool(db=db)
77
142
  result = tool.call({"node_labels": ["Person"]})
@@ -33,7 +33,7 @@ class NodeSchemaTool(BaseTool):
33
33
  )
34
34
  self.db = db
35
35
 
36
- def call(self, arguments: dict[str, Any]) -> list[dict[str, Any]]:
36
+ def call(self, arguments: dict[str, Any]) -> dict[str, Any] | list[dict[str, Any]]:
37
37
  try:
38
38
  node_labels = arguments["node_labels"]
39
39
  schema = _get_schema(self.db)
@@ -96,7 +96,7 @@ class RelationshipSchemaTool(BaseTool):
96
96
  )
97
97
  self.db = db
98
98
 
99
- def call(self, arguments: dict[str, Any]) -> list[dict[str, Any]]:
99
+ def call(self, arguments: dict[str, Any]) -> dict[str, Any] | list[dict[str, Any]]:
100
100
  try:
101
101
  edge_type = arguments["relationship_type"]
102
102
  start_node_labels = arguments["start_node_labels"]
@@ -138,7 +138,7 @@ class EnumSchemaTool(BaseTool):
138
138
  )
139
139
  self.db = db
140
140
 
141
- def call(self, arguments: dict[str, Any]) -> list[dict[str, Any]]:
141
+ def call(self, arguments: dict[str, Any]) -> dict[str, Any] | list[dict[str, Any]]:
142
142
  try:
143
143
  enum_name = arguments["enum_name"]
144
144
  schema = _get_schema(self.db)
@@ -7,7 +7,6 @@ except ImportError as e:
7
7
  ) from e
8
8
 
9
9
 
10
- # NOTE: HF_TOKEN has to be set in the environment variables.
11
10
  def get_sentence_transformer_model(
12
11
  name: str = "all-MiniLM-L6-v2",
13
12
  device: str | None = None,
@@ -26,7 +25,6 @@ def get_sentence_transformer_model(
26
25
  SentenceTransformer model. Always returns the underlying model, not DataParallel
27
26
  wrapper because then it's possible to use the whole SentenceTransformer API.
28
27
  """
29
- # Auto-detect device if not specified
30
28
  if device is None:
31
29
  if torch.cuda.is_available():
32
30
  device = "cuda"
@@ -35,13 +33,11 @@ def get_sentence_transformer_model(
35
33
  else:
36
34
  device = "cpu"
37
35
  model = SentenceTransformer(name, device=device)
38
- # Wrap with DataParallel if requested and CUDA is available
39
36
  if use_data_parallel and torch.cuda.is_available():
40
37
  if gpu_ids is None:
41
38
  gpu_ids = list(range(torch.cuda.device_count()))
42
- # Create DataParallel wrapper but return the underlying model
43
39
  parallel_model = torch.nn.DataParallel(model, device_ids=gpu_ids)
44
- # Return the underlying model to avoid DataParallel wrapper issues
40
+ # Keep the public API as SentenceTransformer rather than exposing the wrapper.
45
41
  return parallel_model.module
46
42
  return model
47
43
 
@@ -57,13 +53,11 @@ if __name__ == "__main__":
57
53
  print(f" MPS: {hasattr(torch.backends, 'mps') and torch.backends.mps.is_available()}")
58
54
  print(" CPU: True (always available)")
59
55
 
60
- # Example usage
61
56
  print("\nExample usage:")
62
57
  model = get_sentence_transformer_model()
63
58
  print(f"Model device: {get_model_device(model)}")
64
59
  print(f"Model type: {type(model).__name__}")
65
60
 
66
- # Example of using the model directly (no wrapper needed)
67
61
  print("\nDirect usage example:")
68
62
  sample_texts = ["Hello world", "Graph databases are fast"]
69
63
  print(f"Sample texts: {sample_texts}")
@@ -73,7 +67,6 @@ if __name__ == "__main__":
73
67
  print(" - model.encode(texts, normalize_embeddings=True)")
74
68
  print(" - model.encode(texts, show_progress_bar=True)")
75
69
 
76
- # Test with DataParallel (if multiple GPUs available)
77
70
  if torch.cuda.is_available() and torch.cuda.device_count() > 1:
78
71
  print("\nTesting DataParallel:")
79
72
  parallel_model = get_sentence_transformer_model(use_data_parallel=True)
@@ -0,0 +1,160 @@
1
+ """
2
+ Serialization utilities for Neo4j date/time types.
3
+ """
4
+
5
+ from collections.abc import Iterable
6
+ from typing import Any
7
+
8
+ from neo4j.graph import Node, Path, Relationship
9
+ from neo4j.spatial import Point
10
+
11
+
12
+ def serialize_neo4j_types(value: Any) -> Any:
13
+ """
14
+ Convert Neo4j date/time types to JSON-serializable strings.
15
+
16
+ Args:
17
+ value: The value to serialize
18
+
19
+ Returns:
20
+ The serialized value with Neo4j types converted to strings
21
+ """
22
+ try:
23
+ class_name = value.__class__.__name__
24
+ module_name = getattr(value.__class__, "__module__", "")
25
+
26
+ # Check if it's a Neo4j temporal type
27
+ if "neo4j" in module_name and hasattr(value, "iso_format"):
28
+ neo4j_temporal_types = [
29
+ "Date",
30
+ "Time",
31
+ "DateTime",
32
+ "LocalTime",
33
+ "LocalDateTime",
34
+ ]
35
+ if class_name in neo4j_temporal_types:
36
+ return value.iso_format()
37
+
38
+ # Handle Neo4j Duration type
39
+ if "neo4j" in module_name and class_name == "Duration":
40
+ return str(value)
41
+
42
+ except (AttributeError, TypeError):
43
+ # If we can't access the class name or module, return as-is
44
+ pass
45
+
46
+ return value
47
+
48
+
49
+ def serialize_record_data(record_data: dict) -> dict:
50
+ """
51
+ Serialize a single record's data, handling Neo4j types recursively.
52
+
53
+ Args:
54
+ record_data: Dictionary from a Neo4j record.data()
55
+
56
+ Returns:
57
+ Dictionary with Neo4j types serialized to JSON-safe values
58
+ """
59
+ serialized = {}
60
+ for key, value in record_data.items():
61
+ if isinstance(value, dict):
62
+ serialized[key] = serialize_record_data(value)
63
+ elif isinstance(value, list):
64
+ serialized[key] = [serialize_neo4j_types(item) for item in value]
65
+ else:
66
+ serialized[key] = serialize_neo4j_types(value)
67
+
68
+ return serialized
69
+
70
+
71
+ def serialize_node(node: Node) -> dict:
72
+ """
73
+ Project a neo4j Node into a JSON-safe node record.
74
+
75
+ Preserves the identity that ``Record.data()`` discards: the stable
76
+ ``element_id`` and ``labels``. Property values reuse
77
+ :func:`serialize_record_data` so temporal/duration types stay JSON-safe.
78
+ """
79
+ return {
80
+ "_type": "node",
81
+ "id": node.element_id,
82
+ "labels": sorted(node.labels),
83
+ "properties": serialize_record_data(dict(node)),
84
+ }
85
+
86
+
87
+ def serialize_relationship(rel: Relationship) -> dict:
88
+ """
89
+ Project a neo4j Relationship into a JSON-safe edge record.
90
+
91
+ Keeps the type and the start/end endpoints, which ``Record.data()`` drops.
92
+ ``start``/``end`` are the endpoint nodes' ``element_id``s, or ``None`` when
93
+ the driver did not hydrate an endpoint.
94
+ """
95
+ start = rel.start_node
96
+ end = rel.end_node
97
+ return {
98
+ "_type": "relationship",
99
+ "id": rel.element_id,
100
+ "type": rel.type,
101
+ "start": start.element_id if start is not None else None,
102
+ "end": end.element_id if end is not None else None,
103
+ "properties": serialize_record_data(dict(rel)),
104
+ }
105
+
106
+
107
+ def serialize_path(path: Path) -> dict:
108
+ """Project a neo4j Path into a JSON-safe record of its nodes and relationships."""
109
+ return {
110
+ "_type": "path",
111
+ "nodes": [serialize_node(node) for node in path.nodes],
112
+ "relationships": [serialize_relationship(rel) for rel in path.relationships],
113
+ }
114
+
115
+
116
+ def serialize_point(point: Point) -> dict:
117
+ """Project a neo4j spatial Point into a JSON-safe record."""
118
+ return {
119
+ "_type": "point",
120
+ "srid": point.srid,
121
+ "coordinates": [float(c) for c in point],
122
+ }
123
+
124
+
125
+ def serialize_value(value: Any) -> Any:
126
+ """Serialize a single Cypher result value, preserving its type.
127
+
128
+ The type-preserving counterpart to ``Record.data()`` (which flattens graph
129
+ entities into bare property maps): nodes, relationships and paths become
130
+ ``_type``-tagged objects with their identity and topology intact; points and
131
+ temporals become JSON-safe structures; lists and maps recurse; primitives
132
+ pass through.
133
+ """
134
+ if isinstance(value, Node):
135
+ return serialize_node(value)
136
+ if isinstance(value, Relationship):
137
+ return serialize_relationship(value)
138
+ if isinstance(value, Path):
139
+ return serialize_path(value)
140
+ if isinstance(value, Point):
141
+ return serialize_point(value)
142
+ if isinstance(value, list):
143
+ return [serialize_value(item) for item in value]
144
+ if isinstance(value, dict):
145
+ return {key: serialize_value(item) for key, item in value.items()}
146
+ return serialize_neo4j_types(value)
147
+
148
+
149
+ def serialize_records(records: Iterable[Any]) -> list[dict]:
150
+ """Serialize raw neo4j records into type-preserving rows (one dict per row).
151
+
152
+ Each row keeps its ``RETURN`` column names; each value is serialized with
153
+ :func:`serialize_value`, so nodes/edges/paths retain their identity instead
154
+ of collapsing to bare property maps.
155
+
156
+ Accepts anything iterable of records (a plain ``list``, or a neo4j
157
+ ``Result``/``AsyncResult`` consumed lazily) -- the body only ever iterates
158
+ once and calls ``.items()`` per record, it never needs list operations.
159
+ """
160
+ return [{key: serialize_value(value) for key, value in record.items()} for record in records]
@@ -1,64 +0,0 @@
1
- """
2
- Serialization utilities for Neo4j date/time types.
3
- """
4
-
5
- from typing import Any
6
-
7
-
8
- def serialize_neo4j_types(value: Any) -> Any:
9
- """
10
- Convert Neo4j date/time types to JSON-serializable strings.
11
-
12
- Args:
13
- value: The value to serialize
14
-
15
- Returns:
16
- The serialized value with Neo4j types converted to strings
17
- """
18
- try:
19
- class_name = value.__class__.__name__
20
- module_name = getattr(value.__class__, "__module__", "")
21
-
22
- # Check if it's a Neo4j temporal type
23
- if "neo4j" in module_name and hasattr(value, "iso_format"):
24
- neo4j_temporal_types = [
25
- "Date",
26
- "Time",
27
- "DateTime",
28
- "LocalTime",
29
- "LocalDateTime",
30
- ]
31
- if class_name in neo4j_temporal_types:
32
- return value.iso_format()
33
-
34
- # Handle Neo4j Duration type
35
- if "neo4j" in module_name and class_name == "Duration":
36
- return str(value)
37
-
38
- except (AttributeError, TypeError):
39
- # If we can't access the class name or module, return as-is
40
- pass
41
-
42
- return value
43
-
44
-
45
- def serialize_record_data(record_data: dict) -> dict:
46
- """
47
- Serialize a single record's data, handling Neo4j types recursively.
48
-
49
- Args:
50
- record_data: Dictionary from a Neo4j record.data()
51
-
52
- Returns:
53
- Dictionary with Neo4j types serialized to JSON-safe values
54
- """
55
- serialized = {}
56
- for key, value in record_data.items():
57
- if isinstance(value, dict):
58
- serialized[key] = serialize_record_data(value)
59
- elif isinstance(value, list):
60
- serialized[key] = [serialize_neo4j_types(item) for item in value]
61
- else:
62
- serialized[key] = serialize_neo4j_types(value)
63
-
64
- return serialized