goodmem-semantic-kernel 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.
- goodmem_semantic_kernel/__init__.py +39 -0
- goodmem_semantic_kernel/_client.py +331 -0
- goodmem_semantic_kernel/collection.py +521 -0
- goodmem_semantic_kernel/settings.py +37 -0
- goodmem_semantic_kernel/store.py +143 -0
- goodmem_semantic_kernel-0.1.0.dist-info/METADATA +307 -0
- goodmem_semantic_kernel-0.1.0.dist-info/RECORD +9 -0
- goodmem_semantic_kernel-0.1.0.dist-info/WHEEL +4 -0
- goodmem_semantic_kernel-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""GoodMem Semantic Kernel integration.
|
|
2
|
+
|
|
3
|
+
Provides a drop-in :class:`~goodmem_semantic_kernel.collection.GoodMemCollection` and
|
|
4
|
+
:class:`~goodmem_semantic_kernel.store.GoodMemStore` that back Semantic Kernel's vector
|
|
5
|
+
store abstractions with the GoodMem memory API.
|
|
6
|
+
|
|
7
|
+
Quick start::
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from goodmem_semantic_kernel import GoodMemCollection, GoodMemStore, GoodMemSettings
|
|
11
|
+
from semantic_kernel.data.vector import vectorstoremodel, VectorStoreField
|
|
12
|
+
from typing import Annotated
|
|
13
|
+
|
|
14
|
+
@vectorstoremodel
|
|
15
|
+
@dataclass
|
|
16
|
+
class Note:
|
|
17
|
+
id: Annotated[str | None, VectorStoreField("key")] = None
|
|
18
|
+
content: Annotated[str, VectorStoreField("data", type="str")] = ""
|
|
19
|
+
|
|
20
|
+
async with GoodMemStore() as store:
|
|
21
|
+
coll = store.get_collection(Note, collection_name="notes")
|
|
22
|
+
await coll.ensure_collection_exists()
|
|
23
|
+
keys = await coll.upsert(Note(content="Remember to buy milk"))
|
|
24
|
+
results = await coll.search("grocery list")
|
|
25
|
+
async for r in results.results:
|
|
26
|
+
print(r.record.content, r.score)
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from goodmem_semantic_kernel._client import GoodMemAsyncClient
|
|
30
|
+
from goodmem_semantic_kernel.collection import GoodMemCollection
|
|
31
|
+
from goodmem_semantic_kernel.settings import GoodMemSettings
|
|
32
|
+
from goodmem_semantic_kernel.store import GoodMemStore
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"GoodMemAsyncClient",
|
|
36
|
+
"GoodMemCollection",
|
|
37
|
+
"GoodMemSettings",
|
|
38
|
+
"GoodMemStore",
|
|
39
|
+
]
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
"""Async HTTP client for the GoodMem REST API (internal use only)."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
from typing import Any
|
|
6
|
+
from urllib.parse import quote
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class GoodMemAsyncClient:
|
|
14
|
+
"""Async HTTP wrapper around the GoodMem REST API.
|
|
15
|
+
|
|
16
|
+
This is an internal class; consumers should use ``GoodMemCollection``
|
|
17
|
+
or ``GoodMemStore`` instead.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
base_url: GoodMem server base URL (no trailing slash, no ``/v1``).
|
|
21
|
+
api_key: API key sent in the ``x-api-key`` header.
|
|
22
|
+
http_client: Optional pre-configured ``httpx.AsyncClient`` to use.
|
|
23
|
+
If not provided, one is created and owned by this instance.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
base_url: str,
|
|
29
|
+
api_key: str,
|
|
30
|
+
http_client: httpx.AsyncClient | None = None,
|
|
31
|
+
verify_ssl: bool = True,
|
|
32
|
+
) -> None:
|
|
33
|
+
self._base_url = base_url.rstrip("/")
|
|
34
|
+
self._api_key = api_key.strip()
|
|
35
|
+
self._headers = {"x-api-key": self._api_key}
|
|
36
|
+
self._owned = http_client is None
|
|
37
|
+
self._client = http_client or httpx.AsyncClient(
|
|
38
|
+
base_url=self._base_url,
|
|
39
|
+
headers=self._headers,
|
|
40
|
+
timeout=30.0,
|
|
41
|
+
verify=verify_ssl,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
async def aclose(self) -> None:
|
|
45
|
+
"""Close the underlying HTTP client (only if owned by this instance)."""
|
|
46
|
+
if self._owned:
|
|
47
|
+
await self._client.aclose()
|
|
48
|
+
|
|
49
|
+
async def __aenter__(self) -> "GoodMemAsyncClient":
|
|
50
|
+
return self
|
|
51
|
+
|
|
52
|
+
async def __aexit__(self, *args: Any) -> None:
|
|
53
|
+
await self.aclose()
|
|
54
|
+
|
|
55
|
+
# ------------------------------------------------------------------
|
|
56
|
+
# Spaces
|
|
57
|
+
# ------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
async def list_spaces(self, name_filter: str | None = None) -> list[dict[str, Any]]:
|
|
60
|
+
"""List spaces, optionally filtering by name.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
name_filter: Optional exact-name filter.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
List of space dicts from the API.
|
|
67
|
+
"""
|
|
68
|
+
all_spaces: list[dict[str, Any]] = []
|
|
69
|
+
next_token: str | None = None
|
|
70
|
+
|
|
71
|
+
while True:
|
|
72
|
+
params: dict[str, Any] = {"maxResults": 1000}
|
|
73
|
+
if next_token:
|
|
74
|
+
params["nextToken"] = next_token
|
|
75
|
+
if name_filter:
|
|
76
|
+
params["nameFilter"] = name_filter
|
|
77
|
+
|
|
78
|
+
response = await self._client.get("/v1/spaces", params=params)
|
|
79
|
+
response.raise_for_status()
|
|
80
|
+
|
|
81
|
+
data = response.json()
|
|
82
|
+
all_spaces.extend(data.get("spaces", []))
|
|
83
|
+
|
|
84
|
+
next_token = data.get("nextToken")
|
|
85
|
+
if not next_token:
|
|
86
|
+
break
|
|
87
|
+
|
|
88
|
+
return all_spaces
|
|
89
|
+
|
|
90
|
+
async def get_space(self, space_id: str) -> dict[str, Any] | None:
|
|
91
|
+
"""Get a space by ID.
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
The space dict, or ``None`` on 404.
|
|
95
|
+
"""
|
|
96
|
+
encoded = quote(space_id, safe="")
|
|
97
|
+
response = await self._client.get(f"/v1/spaces/{encoded}")
|
|
98
|
+
if response.status_code == 404:
|
|
99
|
+
return None
|
|
100
|
+
response.raise_for_status()
|
|
101
|
+
return response.json()
|
|
102
|
+
|
|
103
|
+
async def create_space(
|
|
104
|
+
self,
|
|
105
|
+
name: str,
|
|
106
|
+
embedder_id: str,
|
|
107
|
+
space_id: str | None = None,
|
|
108
|
+
) -> dict[str, Any]:
|
|
109
|
+
"""Create a new space.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
name: Human-readable space name (used as the collection name).
|
|
113
|
+
embedder_id: Embedder UUID to attach to the space.
|
|
114
|
+
space_id: Optional client-supplied UUID. Server generates one if omitted.
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
The created space dict containing ``spaceId``.
|
|
118
|
+
"""
|
|
119
|
+
payload: dict[str, Any] = {
|
|
120
|
+
"name": name,
|
|
121
|
+
"spaceEmbedders": [
|
|
122
|
+
{"embedderId": embedder_id, "defaultRetrievalWeight": 1.0}
|
|
123
|
+
],
|
|
124
|
+
"defaultChunkingConfig": {
|
|
125
|
+
"recursive": {
|
|
126
|
+
"chunkSize": 512,
|
|
127
|
+
"chunkOverlap": 64,
|
|
128
|
+
"keepStrategy": "KEEP_END",
|
|
129
|
+
"lengthMeasurement": "CHARACTER_COUNT",
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
}
|
|
133
|
+
if space_id is not None:
|
|
134
|
+
payload["spaceId"] = space_id
|
|
135
|
+
|
|
136
|
+
response = await self._client.post("/v1/spaces", json=payload)
|
|
137
|
+
response.raise_for_status()
|
|
138
|
+
return response.json()
|
|
139
|
+
|
|
140
|
+
async def delete_space(self, space_id: str) -> None:
|
|
141
|
+
"""Delete a space by ID."""
|
|
142
|
+
encoded = quote(space_id, safe="")
|
|
143
|
+
response = await self._client.delete(f"/v1/spaces/{encoded}")
|
|
144
|
+
response.raise_for_status()
|
|
145
|
+
|
|
146
|
+
# ------------------------------------------------------------------
|
|
147
|
+
# Embedders
|
|
148
|
+
# ------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
async def list_embedders(self) -> list[dict[str, Any]]:
|
|
151
|
+
"""List all configured embedders."""
|
|
152
|
+
response = await self._client.get("/v1/embedders")
|
|
153
|
+
response.raise_for_status()
|
|
154
|
+
return response.json().get("embedders", [])
|
|
155
|
+
|
|
156
|
+
# ------------------------------------------------------------------
|
|
157
|
+
# Memories
|
|
158
|
+
# ------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
async def create_memory(
|
|
161
|
+
self,
|
|
162
|
+
space_id: str,
|
|
163
|
+
content: str,
|
|
164
|
+
content_type: str = "text/plain",
|
|
165
|
+
metadata: dict[str, Any] | None = None,
|
|
166
|
+
memory_id: str | None = None,
|
|
167
|
+
) -> dict[str, Any]:
|
|
168
|
+
"""Create a new memory (text).
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
space_id: Target space UUID.
|
|
172
|
+
content: Raw text content to embed.
|
|
173
|
+
content_type: MIME type (default ``text/plain``).
|
|
174
|
+
metadata: Optional JSONB metadata dict.
|
|
175
|
+
memory_id: Optional client-supplied UUID for the memory.
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
API response dict containing ``memoryId``.
|
|
179
|
+
"""
|
|
180
|
+
payload: dict[str, Any] = {
|
|
181
|
+
"spaceId": space_id,
|
|
182
|
+
"originalContent": content,
|
|
183
|
+
"contentType": content_type,
|
|
184
|
+
}
|
|
185
|
+
if metadata:
|
|
186
|
+
payload["metadata"] = metadata
|
|
187
|
+
if memory_id:
|
|
188
|
+
payload["memoryId"] = memory_id
|
|
189
|
+
|
|
190
|
+
response = await self._client.post("/v1/memories", json=payload)
|
|
191
|
+
response.raise_for_status()
|
|
192
|
+
return response.json()
|
|
193
|
+
|
|
194
|
+
async def get_memories_batch(self, memory_ids: list[str]) -> list[dict[str, Any]]:
|
|
195
|
+
"""Batch-fetch memories by ID.
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
memory_ids: List of memory UUIDs to retrieve.
|
|
199
|
+
|
|
200
|
+
Returns:
|
|
201
|
+
List of memory dicts. Missing IDs are silently omitted.
|
|
202
|
+
"""
|
|
203
|
+
if not memory_ids:
|
|
204
|
+
return []
|
|
205
|
+
response = await self._client.post(
|
|
206
|
+
"/v1/memories:batchGet",
|
|
207
|
+
json={"memoryIds": list(memory_ids)},
|
|
208
|
+
)
|
|
209
|
+
response.raise_for_status()
|
|
210
|
+
return response.json().get("memories", [])
|
|
211
|
+
|
|
212
|
+
async def delete_memory(self, memory_id: str) -> None:
|
|
213
|
+
"""Delete a memory by ID. Silently ignores 404."""
|
|
214
|
+
encoded = quote(memory_id, safe="")
|
|
215
|
+
response = await self._client.delete(f"/v1/memories/{encoded}")
|
|
216
|
+
if response.status_code == 404:
|
|
217
|
+
return
|
|
218
|
+
response.raise_for_status()
|
|
219
|
+
|
|
220
|
+
async def retrieve_memories(
|
|
221
|
+
self,
|
|
222
|
+
query: str,
|
|
223
|
+
space_ids: list[str],
|
|
224
|
+
top: int = 5,
|
|
225
|
+
filter_expr: str | None = None,
|
|
226
|
+
) -> list[dict[str, Any]]:
|
|
227
|
+
"""Semantic search over one or more spaces.
|
|
228
|
+
|
|
229
|
+
Posts to ``/v1/memories:retrieve`` and parses the NDJSON response.
|
|
230
|
+
Each line in the response is either a ``retrievedItem`` event
|
|
231
|
+
(containing a chunk and score) or a ``memoryDefinition`` event
|
|
232
|
+
(containing full memory metadata). This method correlates them and
|
|
233
|
+
returns a unified list.
|
|
234
|
+
|
|
235
|
+
Args:
|
|
236
|
+
query: Natural-language search query (server embeds it).
|
|
237
|
+
space_ids: List of space UUIDs to search.
|
|
238
|
+
top: Maximum number of results to return.
|
|
239
|
+
filter_expr: Optional filter expression (reserved for future use).
|
|
240
|
+
|
|
241
|
+
Returns:
|
|
242
|
+
List of dicts with keys ``chunk``, ``memory``, and ``score``:
|
|
243
|
+
|
|
244
|
+
.. code-block:: python
|
|
245
|
+
|
|
246
|
+
[
|
|
247
|
+
{
|
|
248
|
+
"chunk": {...}, # retrievedItem payload
|
|
249
|
+
"memory": {...}, # memoryDefinition payload (may be {})
|
|
250
|
+
"score": 0.87,
|
|
251
|
+
},
|
|
252
|
+
...
|
|
253
|
+
]
|
|
254
|
+
"""
|
|
255
|
+
payload: dict[str, Any] = {
|
|
256
|
+
"message": query,
|
|
257
|
+
"spaceKeys": [{"spaceId": sid} for sid in space_ids],
|
|
258
|
+
"requestedSize": top,
|
|
259
|
+
}
|
|
260
|
+
if filter_expr:
|
|
261
|
+
payload["filterExpression"] = filter_expr
|
|
262
|
+
|
|
263
|
+
headers = {**self._headers, "Accept": "application/x-ndjson"}
|
|
264
|
+
response = await self._client.post(
|
|
265
|
+
"/v1/memories:retrieve",
|
|
266
|
+
json=payload,
|
|
267
|
+
headers=headers,
|
|
268
|
+
)
|
|
269
|
+
response.raise_for_status()
|
|
270
|
+
|
|
271
|
+
# Parse NDJSON events. Each line is one of:
|
|
272
|
+
# {"memoryDefinition": Memory} — client-side memory cache
|
|
273
|
+
# (indexed 0, 1, 2, … by arrival order)
|
|
274
|
+
# {"retrievedItem": {"chunk": ChunkRef}} — a result chunk with score
|
|
275
|
+
# {"resultSetBoundary": ...} — stream markers (ignored)
|
|
276
|
+
# {"status": ...} — warnings (ignored)
|
|
277
|
+
#
|
|
278
|
+
# NOTE: retrievedItem.memory is NOT used by the server ("The server does not use
|
|
279
|
+
# this field in the current implementation." — memory.proto L230). Memory metadata
|
|
280
|
+
# arrives as top-level `memoryDefinition` events.
|
|
281
|
+
#
|
|
282
|
+
# ChunkReference = {"chunk": MemoryChunk, "memoryIndex": int, "relevanceScore": float}
|
|
283
|
+
# MemoryChunk = {"chunkId": str, "memoryId": str, "chunkText": str, ...}
|
|
284
|
+
# memoryIndex = 0-based position of the parent Memory in memory_list
|
|
285
|
+
memory_list: list[dict[str, Any]] = [] # Memory dicts, indexed by arrival order
|
|
286
|
+
chunk_refs: list[dict[str, Any]] = [] # ChunkReference dicts
|
|
287
|
+
|
|
288
|
+
for line in response.text.strip().split("\n"):
|
|
289
|
+
line = line.strip()
|
|
290
|
+
if not line:
|
|
291
|
+
continue
|
|
292
|
+
try:
|
|
293
|
+
event = json.loads(line)
|
|
294
|
+
except json.JSONDecodeError:
|
|
295
|
+
logger.warning("Failed to parse NDJSON line: %s", line)
|
|
296
|
+
continue
|
|
297
|
+
|
|
298
|
+
# Top-level memory definition (client-side cache, correlated by position)
|
|
299
|
+
if "memoryDefinition" in event:
|
|
300
|
+
memory_list.append(event["memoryDefinition"])
|
|
301
|
+
continue
|
|
302
|
+
|
|
303
|
+
item = event.get("retrievedItem")
|
|
304
|
+
if item is None:
|
|
305
|
+
continue
|
|
306
|
+
if "chunk" in item:
|
|
307
|
+
chunk_refs.append(item["chunk"]) # ChunkReference
|
|
308
|
+
|
|
309
|
+
# Build correlated result list.
|
|
310
|
+
# memoryIndex is the 0-based index of the parent memory in memory_list.
|
|
311
|
+
# relevanceScore is a raw pgvector value (negative inner product: lower = more
|
|
312
|
+
# similar). We negate it so callers receive higher-is-better similarity scores.
|
|
313
|
+
results: list[dict[str, Any]] = []
|
|
314
|
+
for chunk_ref in chunk_refs:
|
|
315
|
+
memory_chunk = chunk_ref.get("chunk", {}) # nested MemoryChunk
|
|
316
|
+
memory_index = chunk_ref.get("memoryIndex")
|
|
317
|
+
mem = (
|
|
318
|
+
memory_list[memory_index]
|
|
319
|
+
if (memory_index is not None and 0 <= memory_index < len(memory_list))
|
|
320
|
+
else {}
|
|
321
|
+
)
|
|
322
|
+
raw_score = float(chunk_ref.get("relevanceScore", 0.0))
|
|
323
|
+
results.append(
|
|
324
|
+
{
|
|
325
|
+
"chunk": memory_chunk,
|
|
326
|
+
"memory": mem,
|
|
327
|
+
"score": -raw_score, # negate: GoodMem returns lower-is-better
|
|
328
|
+
}
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
return results
|