langgraph-store-cosmosdb 0.1.0__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.
- langgraph_store_cosmosdb-0.1.0/.gitignore +9 -0
- langgraph_store_cosmosdb-0.1.0/PKG-INFO +67 -0
- langgraph_store_cosmosdb-0.1.0/README.md +50 -0
- langgraph_store_cosmosdb-0.1.0/pyproject.toml +35 -0
- langgraph_store_cosmosdb-0.1.0/src/langgraph_store_cosmosdb/__init__.py +161 -0
- langgraph_store_cosmosdb-0.1.0/tests/test_cosmosdb_semantic.py +107 -0
- langgraph_store_cosmosdb-0.1.0/tests/test_cosmosdb_store.py +83 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: langgraph-store-cosmosdb
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Azure CosmosDB long-term-memory store (BaseStore) for LangGraph — namespaced key/value memory with filters, list_namespaces and native semantic (Cosmos DB vector) search
|
|
5
|
+
Project-URL: Homepage, https://github.com/skamalj/langgraph-store
|
|
6
|
+
Project-URL: Repository, https://github.com/skamalj/langgraph-store.git
|
|
7
|
+
Author-email: Kamal <skamalj@gmail.com>
|
|
8
|
+
Keywords: agent-memory,azure,basestore,cosmosdb,langgraph,long-term-memory,store
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
13
|
+
Requires-Python: >=3.10
|
|
14
|
+
Requires-Dist: azure-cosmos
|
|
15
|
+
Requires-Dist: langgraph-store-core>=0.1.0
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# langgraph-store-cosmosdb
|
|
19
|
+
|
|
20
|
+
An **Azure Cosmos DB (NoSQL)** long-term-memory store (`BaseStore`) for [LangGraph](https://langchain-ai.github.io/langgraph/) — namespaced key/value memory with prefix search, filters, `list_namespaces`, and **native semantic search via Cosmos DB vector search**.
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pip install langgraph-store-cosmosdb
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
from langgraph_store_cosmosdb import CosmosDBStore
|
|
28
|
+
|
|
29
|
+
store = CosmosDBStore(
|
|
30
|
+
endpoint="https://<acct>.documents.azure.com:443/", key="<key>",
|
|
31
|
+
database_name="langgraph", container_name="store",
|
|
32
|
+
)
|
|
33
|
+
store.put(("users", "1", "memories"), "food", {"text": "loves sushi", "kind": "pref"})
|
|
34
|
+
item = store.get(("users", "1", "memories"), "food")
|
|
35
|
+
hits = store.search(("users", "1"), filter={"kind": "pref"})
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Use it as a LangGraph store: `graph.compile(store=CosmosDBStore(...))`. Async methods work too (sync calls run in a thread).
|
|
39
|
+
|
|
40
|
+
## Semantic search (Cosmos DB vector search)
|
|
41
|
+
|
|
42
|
+
Pass a LangGraph `IndexConfig` and the store embeds the configured fields on `put` and ranks `search(query=...)` by cosine similarity using `VectorDistance` in the container's SQL:
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
from langgraph_store_core import bedrock_titan_embeddings
|
|
46
|
+
from langgraph_store_cosmosdb import CosmosDBStore
|
|
47
|
+
|
|
48
|
+
store = CosmosDBStore(endpoint=..., key=..., database_name="langgraph", container_name="memory",
|
|
49
|
+
index={"dims": 1024, "embed": bedrock_titan_embeddings(dimensions=1024), "fields": ["text"]})
|
|
50
|
+
store.put(("memories", "kamal"), "k1", {"text": "the user loves sushi", "kind": "pref"})
|
|
51
|
+
hits = store.search(("memories", "kamal"), query="what food does the user like?", filter={"kind": "pref"})
|
|
52
|
+
print(hits[0].score, hits[0].value)
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
`embed` may be any LangChain `Embeddings`, a `list[str] -> list[list[float]]` callable, or a provider string. `fields` defaults to `["$"]` (whole value as JSON). `put(..., index=False)` skips embedding for one item.
|
|
56
|
+
|
|
57
|
+
With `index` the container is created with a vector embedding policy on `/embedding` (cosine, `dims`) and a `diskANN` vector index (`vector_index_type="quantizedFlat"` / `"flat"` to change). The policy must be set at container creation, so use a **new container name** when enabling semantic search on an existing store. The account needs the **Vector Search for NoSQL** capability enabled (`az cosmosdb update ... --capabilities EnableNoSQLVectorSearch`, keeping any existing capabilities). Namespace prefix and plain-equality filters run inside the query; operator filters (`$gt`, `$in`, …) are applied on the returned candidates.
|
|
58
|
+
|
|
59
|
+
## Data model
|
|
60
|
+
|
|
61
|
+
A container partitioned by `/prefix` (the namespace), each item `{id, prefix, key, value, created_at, updated_at[, embedding]}` (`id` is the URL-encoded key). Database and container are auto-created under key-based auth. Search is a `STARTSWITH` prefix query; filters and namespace matching are evaluated in the [core](https://pypi.org/project/langgraph-store-core/).
|
|
62
|
+
|
|
63
|
+
Docs: <https://skamalj.github.io/agentstate-reducer/>
|
|
64
|
+
|
|
65
|
+
## License
|
|
66
|
+
|
|
67
|
+
MIT
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# langgraph-store-cosmosdb
|
|
2
|
+
|
|
3
|
+
An **Azure Cosmos DB (NoSQL)** long-term-memory store (`BaseStore`) for [LangGraph](https://langchain-ai.github.io/langgraph/) — namespaced key/value memory with prefix search, filters, `list_namespaces`, and **native semantic search via Cosmos DB vector search**.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install langgraph-store-cosmosdb
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
from langgraph_store_cosmosdb import CosmosDBStore
|
|
11
|
+
|
|
12
|
+
store = CosmosDBStore(
|
|
13
|
+
endpoint="https://<acct>.documents.azure.com:443/", key="<key>",
|
|
14
|
+
database_name="langgraph", container_name="store",
|
|
15
|
+
)
|
|
16
|
+
store.put(("users", "1", "memories"), "food", {"text": "loves sushi", "kind": "pref"})
|
|
17
|
+
item = store.get(("users", "1", "memories"), "food")
|
|
18
|
+
hits = store.search(("users", "1"), filter={"kind": "pref"})
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Use it as a LangGraph store: `graph.compile(store=CosmosDBStore(...))`. Async methods work too (sync calls run in a thread).
|
|
22
|
+
|
|
23
|
+
## Semantic search (Cosmos DB vector search)
|
|
24
|
+
|
|
25
|
+
Pass a LangGraph `IndexConfig` and the store embeds the configured fields on `put` and ranks `search(query=...)` by cosine similarity using `VectorDistance` in the container's SQL:
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
from langgraph_store_core import bedrock_titan_embeddings
|
|
29
|
+
from langgraph_store_cosmosdb import CosmosDBStore
|
|
30
|
+
|
|
31
|
+
store = CosmosDBStore(endpoint=..., key=..., database_name="langgraph", container_name="memory",
|
|
32
|
+
index={"dims": 1024, "embed": bedrock_titan_embeddings(dimensions=1024), "fields": ["text"]})
|
|
33
|
+
store.put(("memories", "kamal"), "k1", {"text": "the user loves sushi", "kind": "pref"})
|
|
34
|
+
hits = store.search(("memories", "kamal"), query="what food does the user like?", filter={"kind": "pref"})
|
|
35
|
+
print(hits[0].score, hits[0].value)
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
`embed` may be any LangChain `Embeddings`, a `list[str] -> list[list[float]]` callable, or a provider string. `fields` defaults to `["$"]` (whole value as JSON). `put(..., index=False)` skips embedding for one item.
|
|
39
|
+
|
|
40
|
+
With `index` the container is created with a vector embedding policy on `/embedding` (cosine, `dims`) and a `diskANN` vector index (`vector_index_type="quantizedFlat"` / `"flat"` to change). The policy must be set at container creation, so use a **new container name** when enabling semantic search on an existing store. The account needs the **Vector Search for NoSQL** capability enabled (`az cosmosdb update ... --capabilities EnableNoSQLVectorSearch`, keeping any existing capabilities). Namespace prefix and plain-equality filters run inside the query; operator filters (`$gt`, `$in`, …) are applied on the returned candidates.
|
|
41
|
+
|
|
42
|
+
## Data model
|
|
43
|
+
|
|
44
|
+
A container partitioned by `/prefix` (the namespace), each item `{id, prefix, key, value, created_at, updated_at[, embedding]}` (`id` is the URL-encoded key). Database and container are auto-created under key-based auth. Search is a `STARTSWITH` prefix query; filters and namespace matching are evaluated in the [core](https://pypi.org/project/langgraph-store-core/).
|
|
45
|
+
|
|
46
|
+
Docs: <https://skamalj.github.io/agentstate-reducer/>
|
|
47
|
+
|
|
48
|
+
## License
|
|
49
|
+
|
|
50
|
+
MIT
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "langgraph-store-cosmosdb"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Azure CosmosDB long-term-memory store (BaseStore) for LangGraph — namespaced key/value memory with filters, list_namespaces and native semantic (Cosmos DB vector) search"
|
|
9
|
+
authors = [{name = "Kamal", email = "skamalj@gmail.com"}]
|
|
10
|
+
readme = "README.md"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
dependencies = [
|
|
13
|
+
"langgraph-store-core>=0.1.0",
|
|
14
|
+
"azure-cosmos",
|
|
15
|
+
]
|
|
16
|
+
keywords = ["langgraph", "store", "basestore", "cosmosdb", "azure", "long-term-memory", "agent-memory"]
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"License :: OSI Approved :: MIT License",
|
|
20
|
+
"Operating System :: OS Independent",
|
|
21
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[project.urls]
|
|
25
|
+
Homepage = "https://github.com/skamalj/langgraph-store"
|
|
26
|
+
Repository = "https://github.com/skamalj/langgraph-store.git"
|
|
27
|
+
|
|
28
|
+
[dependency-groups]
|
|
29
|
+
dev = ["pytest>=7.0", "pytest-asyncio"]
|
|
30
|
+
|
|
31
|
+
[tool.pytest.ini_options]
|
|
32
|
+
asyncio_mode = "auto"
|
|
33
|
+
|
|
34
|
+
[tool.hatch.build.targets.wheel]
|
|
35
|
+
packages = ["src/langgraph_store_cosmosdb"]
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Azure CosmosDB implementation of the LangGraph long-term-memory ``BaseStore``.
|
|
2
|
+
|
|
3
|
+
``CosmosDBStore`` persists namespaced key/value memory in a container partitioned
|
|
4
|
+
by namespace, and supports get / put / delete / search (namespace-prefix + filter)
|
|
5
|
+
and ``list_namespaces``. With an ``IndexConfig`` it also does **semantic search**
|
|
6
|
+
natively via Cosmos DB NoSQL vector search (``VectorDistance``, cosine).
|
|
7
|
+
Built on ``langgraph-store-core``.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import List, Optional, Tuple
|
|
13
|
+
from urllib.parse import quote
|
|
14
|
+
|
|
15
|
+
from azure.cosmos import CosmosClient, PartitionKey
|
|
16
|
+
from azure.cosmos.exceptions import CosmosResourceNotFoundError
|
|
17
|
+
|
|
18
|
+
from langgraph_store_core import IndexConfig, KVStore
|
|
19
|
+
|
|
20
|
+
__all__ = ["CosmosDBStore"]
|
|
21
|
+
|
|
22
|
+
_FIELDS = ("prefix", "key", "value", "created_at", "updated_at")
|
|
23
|
+
_PROJECTION = ", ".join(f'c["{f}"]' for f in _FIELDS) # value/key are reserved words
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class CosmosDBStore(KVStore):
|
|
27
|
+
"""LangGraph ``BaseStore`` backed by Azure CosmosDB.
|
|
28
|
+
|
|
29
|
+
Example:
|
|
30
|
+
```python
|
|
31
|
+
from langgraph_store_cosmosdb import CosmosDBStore
|
|
32
|
+
|
|
33
|
+
store = CosmosDBStore(
|
|
34
|
+
endpoint="https://<acct>.documents.azure.com:443/", key="<key>",
|
|
35
|
+
database_name="langgraph", container_name="store",
|
|
36
|
+
)
|
|
37
|
+
store.put(("users", "1"), "profile", {"name": "Kamal"})
|
|
38
|
+
|
|
39
|
+
# semantic search
|
|
40
|
+
store = CosmosDBStore(..., index={"dims": 1024, "embed": my_embedder, "fields": ["text"]})
|
|
41
|
+
hits = store.search(("users", "1"), query="what does the user like?")
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
The container is partitioned by ``/prefix`` (the namespace) and auto-created
|
|
45
|
+
under key-based auth. With ``index`` the container is created with a vector
|
|
46
|
+
embedding policy on ``/embedding`` (cosine, ``dims``) and a ``diskANN``
|
|
47
|
+
vector index; the policy must be set at container creation, so use a new
|
|
48
|
+
container name when enabling semantic search on an existing store.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
supports_native_vector_search = True
|
|
52
|
+
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
*,
|
|
56
|
+
endpoint: str,
|
|
57
|
+
key: str,
|
|
58
|
+
database_name: str,
|
|
59
|
+
container_name: str,
|
|
60
|
+
index: Optional[IndexConfig] = None,
|
|
61
|
+
vector_index_type: str = "diskANN",
|
|
62
|
+
) -> None:
|
|
63
|
+
super().__init__(index=index)
|
|
64
|
+
client = CosmosClient(endpoint, credential=key)
|
|
65
|
+
db = client.create_database_if_not_exists(database_name)
|
|
66
|
+
kwargs = {}
|
|
67
|
+
if self.dims:
|
|
68
|
+
kwargs["vector_embedding_policy"] = {
|
|
69
|
+
"vectorEmbeddings": [
|
|
70
|
+
{
|
|
71
|
+
"path": "/embedding",
|
|
72
|
+
"dataType": "float32",
|
|
73
|
+
"distanceFunction": "cosine",
|
|
74
|
+
"dimensions": self.dims,
|
|
75
|
+
}
|
|
76
|
+
]
|
|
77
|
+
}
|
|
78
|
+
kwargs["indexing_policy"] = {
|
|
79
|
+
"automatic": True,
|
|
80
|
+
"indexingMode": "consistent",
|
|
81
|
+
"includedPaths": [{"path": "/*"}],
|
|
82
|
+
# vector paths must be excluded from the range index
|
|
83
|
+
"excludedPaths": [{"path": "/embedding/*"}, {"path": '/"_etag"/?'}],
|
|
84
|
+
"vectorIndexes": [{"path": "/embedding", "type": vector_index_type}],
|
|
85
|
+
}
|
|
86
|
+
self._container = db.create_container_if_not_exists(
|
|
87
|
+
id=container_name, partition_key=PartitionKey(path="/prefix"), **kwargs
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
@staticmethod
|
|
91
|
+
def _id(key: str) -> str:
|
|
92
|
+
return quote(key, safe="") # Cosmos ids may not contain # / \ ?
|
|
93
|
+
|
|
94
|
+
def _row(self, item: dict) -> dict:
|
|
95
|
+
row = {f: item[f] for f in _FIELDS}
|
|
96
|
+
row["embedding"] = item.get("embedding")
|
|
97
|
+
return row
|
|
98
|
+
|
|
99
|
+
def _read(self, prefix: str, key: str) -> Optional[dict]:
|
|
100
|
+
try:
|
|
101
|
+
item = self._container.read_item(item=self._id(key), partition_key=prefix)
|
|
102
|
+
except CosmosResourceNotFoundError:
|
|
103
|
+
return None
|
|
104
|
+
return self._row(item)
|
|
105
|
+
|
|
106
|
+
def _write(self, row: dict) -> None:
|
|
107
|
+
doc = {"id": self._id(row["key"]), **{f: row[f] for f in _FIELDS}}
|
|
108
|
+
if self.dims and row.get("embedding") is not None:
|
|
109
|
+
doc["embedding"] = row["embedding"]
|
|
110
|
+
self._container.upsert_item(doc)
|
|
111
|
+
|
|
112
|
+
def _remove(self, prefix: str, key: str) -> None:
|
|
113
|
+
try:
|
|
114
|
+
self._container.delete_item(item=self._id(key), partition_key=prefix)
|
|
115
|
+
except CosmosResourceNotFoundError:
|
|
116
|
+
pass
|
|
117
|
+
|
|
118
|
+
def _scan(self, prefix: str) -> List[dict]:
|
|
119
|
+
proj = _PROJECTION + (', c["embedding"]' if self.dims else "")
|
|
120
|
+
if prefix == "":
|
|
121
|
+
query = f"SELECT {proj} FROM c"
|
|
122
|
+
params: list = []
|
|
123
|
+
else:
|
|
124
|
+
query = f'SELECT {proj} FROM c WHERE STARTSWITH(c["prefix"], @p)'
|
|
125
|
+
params = [{"name": "@p", "value": prefix}]
|
|
126
|
+
items = self._container.query_items(
|
|
127
|
+
query=query, parameters=params, enable_cross_partition_query=True
|
|
128
|
+
)
|
|
129
|
+
return [self._row(i) for i in items]
|
|
130
|
+
|
|
131
|
+
def _vector_search(
|
|
132
|
+
self, prefix: str, vector: List[float], filter: Optional[dict], limit: int
|
|
133
|
+
) -> List[Tuple[dict, float]]:
|
|
134
|
+
"""Native Cosmos vector search: ORDER BY VectorDistance, prefix + equality filters in SQL."""
|
|
135
|
+
where = ['IS_DEFINED(c["embedding"])']
|
|
136
|
+
params: list = [{"name": "@q", "value": vector}, {"name": "@k", "value": int(limit)}]
|
|
137
|
+
if prefix != "":
|
|
138
|
+
where.append('STARTSWITH(c["prefix"], @p)')
|
|
139
|
+
params.append({"name": "@p", "value": prefix})
|
|
140
|
+
# Push plain-equality filters into SQL; operator filters are applied by core.
|
|
141
|
+
n = 0
|
|
142
|
+
for field, expected in (filter or {}).items():
|
|
143
|
+
if not isinstance(expected, dict):
|
|
144
|
+
n += 1
|
|
145
|
+
where.append(f'c["value"][@f{n}] = @v{n}')
|
|
146
|
+
params += [{"name": f"@f{n}", "value": field}, {"name": f"@v{n}", "value": expected}]
|
|
147
|
+
query = (
|
|
148
|
+
f"SELECT TOP @k {_PROJECTION}, "
|
|
149
|
+
f'VectorDistance(c["embedding"], @q) AS score '
|
|
150
|
+
f"FROM c WHERE {' AND '.join(where)} "
|
|
151
|
+
f'ORDER BY VectorDistance(c["embedding"], @q)'
|
|
152
|
+
)
|
|
153
|
+
items = self._container.query_items(
|
|
154
|
+
query=query, parameters=params, enable_cross_partition_query=True
|
|
155
|
+
)
|
|
156
|
+
out: List[Tuple[dict, float]] = []
|
|
157
|
+
for i in items:
|
|
158
|
+
row = {f: i[f] for f in _FIELDS}
|
|
159
|
+
row["embedding"] = None # not projected; not needed after ranking
|
|
160
|
+
out.append((row, float(i["score"]))) # cosine: similarity, higher is better
|
|
161
|
+
return out
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Semantic-search tests for CosmosDBStore (native VectorDistance path) against a real Cosmos DB account."""
|
|
2
|
+
import os
|
|
3
|
+
import time
|
|
4
|
+
import uuid
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from langgraph_store_core.testing import FakeEmbeddings
|
|
9
|
+
from langgraph_store_cosmosdb import CosmosDBStore
|
|
10
|
+
|
|
11
|
+
EP = os.environ.get("COSMOS_ENDPOINT")
|
|
12
|
+
KEY = os.environ.get("COSMOS_KEY")
|
|
13
|
+
DB = os.environ.get("LG_COSMOS_DB", "langgraph_store_test")
|
|
14
|
+
DIMS = 64
|
|
15
|
+
|
|
16
|
+
pytestmark = pytest.mark.skipif(not (EP and KEY), reason="COSMOS_ENDPOINT/COSMOS_KEY not set")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@pytest.fixture(scope="module")
|
|
20
|
+
def store():
|
|
21
|
+
cname = f"vstore_{uuid.uuid4().hex[:8]}"
|
|
22
|
+
s = CosmosDBStore(endpoint=EP, key=KEY, database_name=DB, container_name=cname,
|
|
23
|
+
index={"dims": DIMS, "embed": FakeEmbeddings(DIMS), "fields": ["text"]})
|
|
24
|
+
yield s
|
|
25
|
+
s._container.database_link # noqa: B018 — keep proxy alive
|
|
26
|
+
from azure.cosmos import CosmosClient
|
|
27
|
+
CosmosClient(EP, credential=KEY).get_database_client(DB).delete_container(cname)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@pytest.fixture(autouse=True)
|
|
31
|
+
def _clean(store):
|
|
32
|
+
yield
|
|
33
|
+
for row in store._scan(""):
|
|
34
|
+
store._remove(row["prefix"], row["key"])
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _seed(s):
|
|
38
|
+
s.put(("memories", "kamal"), "food", {"text": "the user loves sushi and japanese food", "kind": "pref"})
|
|
39
|
+
s.put(("memories", "kamal"), "lang", {"text": "the user writes python every day", "kind": "fact"})
|
|
40
|
+
s.put(("memories", "kamal"), "city", {"text": "the user lives by the sea in a big city", "kind": "fact"})
|
|
41
|
+
s.put(("memories", "priya"), "food", {"text": "loves sushi", "kind": "pref"})
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _search(s, *a, **k):
|
|
45
|
+
# vector index population is asynchronous; retry briefly until results appear
|
|
46
|
+
for _ in range(12):
|
|
47
|
+
hits = s.search(*a, **k)
|
|
48
|
+
if hits:
|
|
49
|
+
return hits
|
|
50
|
+
time.sleep(2)
|
|
51
|
+
return s.search(*a, **k)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_container_has_vector_policy(store):
|
|
55
|
+
props = store._container.read()
|
|
56
|
+
assert props.get("vectorEmbeddingPolicy", {}).get("vectorEmbeddings", [{}])[0].get("path") == "/embedding"
|
|
57
|
+
assert any(v.get("path") == "/embedding" for v in props["indexingPolicy"].get("vectorIndexes", []))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_put_embeds_and_index_false_skips(store):
|
|
61
|
+
store.put(("n",), "k", {"text": "hello world"})
|
|
62
|
+
assert len(store._read("n", "k")["embedding"]) == DIMS
|
|
63
|
+
store.put(("n",), "k2", {"text": "hello"}, index=False)
|
|
64
|
+
assert store._read("n", "k2")["embedding"] is None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_query_ranks_and_scores(store):
|
|
68
|
+
_seed(store)
|
|
69
|
+
hits = _search(store, ("memories", "kamal"), query="sushi and japanese food")
|
|
70
|
+
assert hits[0].key == "food" and hits[0].score is not None
|
|
71
|
+
assert hits[0].score >= hits[-1].score
|
|
72
|
+
assert all(h.namespace == ("memories", "kamal") for h in hits)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_query_prefix_children_and_filter(store):
|
|
76
|
+
_seed(store)
|
|
77
|
+
keys = {(h.namespace, h.key) for h in _search(store, ("memories",), query="sushi")}
|
|
78
|
+
assert (("memories", "kamal"), "food") in keys and (("memories", "priya"), "food") in keys
|
|
79
|
+
facts = _search(store, ("memories", "kamal"), query="user", filter={"kind": "fact"})
|
|
80
|
+
assert facts and all(h.value["kind"] == "fact" for h in facts)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_query_limit_offset(store):
|
|
84
|
+
_seed(store)
|
|
85
|
+
first = _search(store, ("memories", "kamal"), query="user", limit=2)
|
|
86
|
+
rest = store.search(("memories", "kamal"), query="user", limit=2, offset=2)
|
|
87
|
+
assert len(first) == 2 and len(rest) == 1 and not {h.key for h in first} & {h.key for h in rest}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_no_query_is_filter_only(store):
|
|
91
|
+
_seed(store)
|
|
92
|
+
hits = store.search(("memories", "kamal"))
|
|
93
|
+
assert [h.key for h in hits] == ["city", "food", "lang"] and all(h.score is None for h in hits)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_native_path_used(store, monkeypatch):
|
|
97
|
+
_seed(store)
|
|
98
|
+
called = []
|
|
99
|
+
orig = store._container.query_items
|
|
100
|
+
|
|
101
|
+
def spy(query, **kw):
|
|
102
|
+
called.append(query)
|
|
103
|
+
return orig(query=query, **kw)
|
|
104
|
+
|
|
105
|
+
monkeypatch.setattr(store._container, "query_items", spy)
|
|
106
|
+
_search(store, ("memories",), query="sushi")
|
|
107
|
+
assert any("VectorDistance" in q for q in called)
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Tests for CosmosDBStore (LangGraph BaseStore) against real CosmosDB.
|
|
2
|
+
|
|
3
|
+
Requires COSMOS_ENDPOINT + COSMOS_KEY. A unique container per test gives clean
|
|
4
|
+
isolation (deleted on teardown).
|
|
5
|
+
"""
|
|
6
|
+
import os
|
|
7
|
+
import uuid
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from langgraph_store_cosmosdb import CosmosDBStore
|
|
12
|
+
|
|
13
|
+
EP = os.environ.get("COSMOS_ENDPOINT")
|
|
14
|
+
KEY = os.environ.get("COSMOS_KEY")
|
|
15
|
+
DB = os.environ.get("LG_COSMOS_DB", "langgraph_store_test")
|
|
16
|
+
|
|
17
|
+
pytestmark = pytest.mark.skipif(not (EP and KEY), reason="COSMOS_ENDPOINT/COSMOS_KEY not set")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@pytest.fixture()
|
|
21
|
+
def store():
|
|
22
|
+
cname = f"test_{uuid.uuid4().hex[:8]}"
|
|
23
|
+
s = CosmosDBStore(endpoint=EP, key=KEY, database_name=DB, container_name=cname)
|
|
24
|
+
yield s
|
|
25
|
+
from azure.cosmos import CosmosClient
|
|
26
|
+
CosmosClient(EP, credential=KEY).get_database_client(DB).delete_container(cname)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_put_get(store):
|
|
30
|
+
store.put(("users", "1"), "food", {"text": "sushi"})
|
|
31
|
+
it = store.get(("users", "1"), "food")
|
|
32
|
+
assert it is not None and it.value == {"text": "sushi"}
|
|
33
|
+
assert it.namespace == ("users", "1") and it.key == "food"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_get_missing_is_none(store):
|
|
37
|
+
assert store.get(("nope",), "x") is None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_update_preserves_created_at(store):
|
|
41
|
+
store.put(("u",), "k", {"v": 1})
|
|
42
|
+
a = store.get(("u",), "k")
|
|
43
|
+
store.put(("u",), "k", {"v": 2})
|
|
44
|
+
b = store.get(("u",), "k")
|
|
45
|
+
assert b.value == {"v": 2}
|
|
46
|
+
assert b.created_at == a.created_at
|
|
47
|
+
assert b.updated_at >= a.updated_at
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_delete_both_ways(store):
|
|
51
|
+
store.put(("u",), "k", {"v": 1})
|
|
52
|
+
store.delete(("u",), "k")
|
|
53
|
+
assert store.get(("u",), "k") is None
|
|
54
|
+
store.put(("u",), "k2", {"v": 1})
|
|
55
|
+
store.put(("u",), "k2", None)
|
|
56
|
+
assert store.get(("u",), "k2") is None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_search_prefix_and_filter(store):
|
|
60
|
+
store.put(("users", "1", "mem"), "a", {"kind": "pref"})
|
|
61
|
+
store.put(("users", "1", "mem"), "b", {"kind": "fact"})
|
|
62
|
+
store.put(("users", "2", "mem"), "c", {"kind": "pref"})
|
|
63
|
+
assert sorted(i.key for i in store.search(("users", "1"))) == ["a", "b"]
|
|
64
|
+
assert [i.key for i in store.search(("users", "1"), filter={"kind": "pref"})] == ["a"]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_list_namespaces(store):
|
|
68
|
+
store.put(("users", "1", "mem"), "a", {})
|
|
69
|
+
store.put(("users", "2", "mem"), "b", {})
|
|
70
|
+
store.put(("orgs", "x"), "c", {})
|
|
71
|
+
allns = store.list_namespaces()
|
|
72
|
+
assert ("users", "1", "mem") in allns and ("orgs", "x") in allns
|
|
73
|
+
assert all(n[0] == "users" for n in store.list_namespaces(prefix=("users",)))
|
|
74
|
+
depth1 = store.list_namespaces(max_depth=1)
|
|
75
|
+
assert ("users",) in depth1 and ("orgs",) in depth1
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
async def test_async_surface(store):
|
|
79
|
+
await store.aput(("u",), "k", {"v": 1})
|
|
80
|
+
it = await store.aget(("u",), "k")
|
|
81
|
+
assert it.value == {"v": 1}
|
|
82
|
+
await store.adelete(("u",), "k")
|
|
83
|
+
assert await store.aget(("u",), "k") is None
|