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.
@@ -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