memcode-sdk 2.3.1__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.
- memcode_sdk/__init__.py +104 -0
- memcode_sdk/_http.py +162 -0
- memcode_sdk/async_client.py +277 -0
- memcode_sdk/async_v2_client.py +147 -0
- memcode_sdk/client.py +576 -0
- memcode_sdk/errors.py +64 -0
- memcode_sdk/py.typed +1 -0
- memcode_sdk/types.py +172 -0
- memcode_sdk/v2_client.py +214 -0
- memcode_sdk/v2_types.py +116 -0
- memcode_sdk-2.3.1.dist-info/METADATA +387 -0
- memcode_sdk-2.3.1.dist-info/RECORD +14 -0
- memcode_sdk-2.3.1.dist-info/WHEEL +5 -0
- memcode_sdk-2.3.1.dist-info/top_level.txt +1 -0
memcode_sdk/client.py
ADDED
|
@@ -0,0 +1,576 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Synchronous Memcode client.
|
|
3
|
+
|
|
4
|
+
Usage::
|
|
5
|
+
|
|
6
|
+
from memcode_sdk import MemcodeClient
|
|
7
|
+
|
|
8
|
+
client = MemcodeClient(api_url="http://localhost:8000", api_key="sk-...")
|
|
9
|
+
|
|
10
|
+
# Health check
|
|
11
|
+
health = client.ping()
|
|
12
|
+
|
|
13
|
+
# Ingest a conversation turn
|
|
14
|
+
result = client.ingest(
|
|
15
|
+
user_query="I just got promoted to senior engineer!",
|
|
16
|
+
agent_response="Congratulations on your promotion!",
|
|
17
|
+
user_id="user_42",
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
# Retrieve an answer grounded in stored memories
|
|
21
|
+
answer = client.retrieve(query="What is my job title?", user_id="user_42")
|
|
22
|
+
print(answer.answer)
|
|
23
|
+
|
|
24
|
+
# Raw semantic search
|
|
25
|
+
hits = client.search(query="work", user_id="user_42", domains=["profile"])
|
|
26
|
+
for r in hits.results:
|
|
27
|
+
print(r.domain, r.content, r.score)
|
|
28
|
+
|
|
29
|
+
client.close()
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import os
|
|
35
|
+
import warnings
|
|
36
|
+
from typing import Any, Dict, List, Optional
|
|
37
|
+
|
|
38
|
+
from ._http import DEFAULT_TIMEOUT, SyncTransport
|
|
39
|
+
from .types import (
|
|
40
|
+
DomainResult,
|
|
41
|
+
HealthStatus,
|
|
42
|
+
HybridSearchResult,
|
|
43
|
+
IngestResult,
|
|
44
|
+
OperationDetail,
|
|
45
|
+
PersonalV2IngestResult,
|
|
46
|
+
PersonalV2IngestStatus,
|
|
47
|
+
PersonalV2RetrieveResult,
|
|
48
|
+
RetrieveResult,
|
|
49
|
+
SearchResult,
|
|
50
|
+
SourceRecord,
|
|
51
|
+
WeaverSummary,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class MemcodeClient:
|
|
56
|
+
"""Synchronous client for compatible v1 and advanced personal v2 APIs."""
|
|
57
|
+
|
|
58
|
+
def __init__(
|
|
59
|
+
self,
|
|
60
|
+
api_url: Optional[str] = None,
|
|
61
|
+
api_key: Optional[str] = None,
|
|
62
|
+
timeout: int = DEFAULT_TIMEOUT,
|
|
63
|
+
) -> None:
|
|
64
|
+
self._api_url = (
|
|
65
|
+
api_url
|
|
66
|
+
or os.getenv("MEMCODE_API_URL")
|
|
67
|
+
or "http://localhost:8000"
|
|
68
|
+
)
|
|
69
|
+
self._api_key = api_key or os.getenv("MEMCODE_API_KEY") or ""
|
|
70
|
+
self._transport = SyncTransport(self._api_url, self._api_key, timeout)
|
|
71
|
+
|
|
72
|
+
# ── Health ─────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
def ping(self) -> HealthStatus:
|
|
75
|
+
"""Check the Memcode API health. Never raises on a valid HTTP response."""
|
|
76
|
+
try:
|
|
77
|
+
env = self._transport.get("/health")
|
|
78
|
+
d = env.data or {}
|
|
79
|
+
except Exception:
|
|
80
|
+
return HealthStatus(status="unreachable", pipelines_ready=False)
|
|
81
|
+
|
|
82
|
+
return HealthStatus(
|
|
83
|
+
status=d.get("status", "unknown"),
|
|
84
|
+
pipelines_ready=d.get("pipelines_ready", False),
|
|
85
|
+
version=d.get("version", ""),
|
|
86
|
+
uptime_seconds=d.get("uptime_seconds"),
|
|
87
|
+
error=d.get("error"),
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
def is_ready(self) -> bool:
|
|
91
|
+
"""Return ``True`` if the API is healthy and pipelines are loaded."""
|
|
92
|
+
return self.ping().pipelines_ready
|
|
93
|
+
|
|
94
|
+
# ── Ingest ─────────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
def ingest(
|
|
97
|
+
self,
|
|
98
|
+
user_query: str,
|
|
99
|
+
user_id: str,
|
|
100
|
+
agent_response: str = "",
|
|
101
|
+
session_datetime: str = "",
|
|
102
|
+
image_url: str = "",
|
|
103
|
+
) -> IngestResult:
|
|
104
|
+
"""Ingest a conversation turn into long-term memory.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
user_query: The user's message to memorize.
|
|
108
|
+
user_id: Unique user identifier.
|
|
109
|
+
agent_response: Assistant reply (improves summary extraction).
|
|
110
|
+
session_datetime: ISO-8601 datetime for temporal extraction.
|
|
111
|
+
image_url: URL or base64 data-URI of an attached image.
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
IngestResult with classification, domain results, and timing.
|
|
115
|
+
"""
|
|
116
|
+
payload: Dict[str, Any] = {
|
|
117
|
+
"user_query": user_query,
|
|
118
|
+
"user_id": user_id,
|
|
119
|
+
}
|
|
120
|
+
if agent_response:
|
|
121
|
+
payload["agent_response"] = agent_response
|
|
122
|
+
if session_datetime:
|
|
123
|
+
payload["session_datetime"] = session_datetime
|
|
124
|
+
if image_url:
|
|
125
|
+
payload["image_url"] = image_url
|
|
126
|
+
|
|
127
|
+
env = self._transport.post("/v1/memory/ingest", json=payload)
|
|
128
|
+
return _parse_ingest(env.data or {}, env.request_id, env.elapsed_ms)
|
|
129
|
+
|
|
130
|
+
def ingest_v2(
|
|
131
|
+
self,
|
|
132
|
+
user_query: str,
|
|
133
|
+
user_id: Optional[str] = None,
|
|
134
|
+
agent_response: str = "",
|
|
135
|
+
session_datetime: str = "",
|
|
136
|
+
image_url: str = "",
|
|
137
|
+
effort_level: str = "low",
|
|
138
|
+
forget: bool = False,
|
|
139
|
+
idempotency_key: str = "",
|
|
140
|
+
) -> PersonalV2IngestResult:
|
|
141
|
+
"""Start an advanced durable personal v2 ingest job.
|
|
142
|
+
|
|
143
|
+
``user_id`` is a deprecated compatibility field. Authenticated API
|
|
144
|
+
keys and JWTs determine the personal user, so new callers should omit
|
|
145
|
+
it.
|
|
146
|
+
"""
|
|
147
|
+
payload = _personal_v2_ingest_payload(
|
|
148
|
+
user_query,
|
|
149
|
+
user_id,
|
|
150
|
+
agent_response,
|
|
151
|
+
session_datetime,
|
|
152
|
+
image_url,
|
|
153
|
+
effort_level,
|
|
154
|
+
forget,
|
|
155
|
+
)
|
|
156
|
+
headers = _optional_idempotency_headers(idempotency_key)
|
|
157
|
+
env = self._transport.post("/v2/memory/ingest", json=payload, headers=headers)
|
|
158
|
+
return _parse_personal_v2_ingest(env.data or {}, env.request_id, env.elapsed_ms)
|
|
159
|
+
|
|
160
|
+
def get_ingest_status_v2(self, job_id: str) -> PersonalV2IngestStatus:
|
|
161
|
+
"""Poll a personal v2 ingest job using its normal-user status route."""
|
|
162
|
+
from urllib.parse import quote
|
|
163
|
+
|
|
164
|
+
normalized = str(job_id or "").strip()
|
|
165
|
+
if not normalized:
|
|
166
|
+
raise ValueError("MemcodeClient: job_id is required")
|
|
167
|
+
env = self._transport.get(f"/v2/memory/ingest/{quote(normalized, safe='')}/status")
|
|
168
|
+
return _parse_personal_v2_status(env.data or {}, env.request_id, env.elapsed_ms)
|
|
169
|
+
|
|
170
|
+
# ── Retrieve ───────────────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
def retrieve(
|
|
173
|
+
self,
|
|
174
|
+
query: str,
|
|
175
|
+
user_id: str,
|
|
176
|
+
top_k: int = 5,
|
|
177
|
+
) -> RetrieveResult:
|
|
178
|
+
"""Answer a question using stored memories.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
query: The question to answer.
|
|
182
|
+
user_id: User identifier.
|
|
183
|
+
top_k: Number of source records to consider.
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
RetrieveResult with the LLM answer, sources, and confidence.
|
|
187
|
+
"""
|
|
188
|
+
env = self._transport.post("/v1/memory/retrieve", json={
|
|
189
|
+
"query": query, "user_id": user_id, "top_k": top_k,
|
|
190
|
+
})
|
|
191
|
+
return _parse_retrieve(env.data or {}, env.request_id, env.elapsed_ms)
|
|
192
|
+
|
|
193
|
+
def retrieve_v2(
|
|
194
|
+
self,
|
|
195
|
+
query: str,
|
|
196
|
+
user_id: Optional[str] = None,
|
|
197
|
+
top_k: int = 5,
|
|
198
|
+
) -> PersonalV2RetrieveResult:
|
|
199
|
+
"""Use advanced personal v2 retrieval with attribution learning.
|
|
200
|
+
|
|
201
|
+
``user_id`` is deprecated and is only sent when explicitly supplied.
|
|
202
|
+
"""
|
|
203
|
+
if isinstance(top_k, bool) or not isinstance(top_k, int) or not 1 <= top_k <= 50:
|
|
204
|
+
raise ValueError("MemcodeClient: top_k must be an integer between 1 and 50")
|
|
205
|
+
payload: Dict[str, Any] = {
|
|
206
|
+
"query": _required_personal("query", query),
|
|
207
|
+
"top_k": top_k,
|
|
208
|
+
}
|
|
209
|
+
_append_legacy_user_id(payload, user_id)
|
|
210
|
+
env = self._transport.post("/v2/memory/retrieve", json=payload)
|
|
211
|
+
return _parse_personal_v2_retrieve(env.data or {}, env.request_id, env.elapsed_ms)
|
|
212
|
+
|
|
213
|
+
# ── Search ─────────────────────────────────────────────────────────
|
|
214
|
+
|
|
215
|
+
def search(
|
|
216
|
+
self,
|
|
217
|
+
query: str,
|
|
218
|
+
user_id: str,
|
|
219
|
+
domains: Optional[List[str]] = None,
|
|
220
|
+
top_k: int = 10,
|
|
221
|
+
) -> SearchResult:
|
|
222
|
+
"""Raw semantic search across memory domains.
|
|
223
|
+
|
|
224
|
+
Args:
|
|
225
|
+
query: Natural-language search query.
|
|
226
|
+
user_id: User identifier.
|
|
227
|
+
domains: Subset of ``["profile", "temporal", "summary"]``.
|
|
228
|
+
top_k: Max results per domain.
|
|
229
|
+
|
|
230
|
+
Returns:
|
|
231
|
+
SearchResult with a flat list of SourceRecords.
|
|
232
|
+
"""
|
|
233
|
+
payload: Dict[str, Any] = {
|
|
234
|
+
"query": query, "user_id": user_id, "top_k": top_k,
|
|
235
|
+
}
|
|
236
|
+
if domains is not None:
|
|
237
|
+
payload["domains"] = domains
|
|
238
|
+
|
|
239
|
+
env = self._transport.post("/v1/memory/search", json=payload)
|
|
240
|
+
return _parse_search(env.data or {}, env.request_id, env.elapsed_ms)
|
|
241
|
+
|
|
242
|
+
def hybrid_search(
|
|
243
|
+
self,
|
|
244
|
+
query: str,
|
|
245
|
+
user_id: Optional[str] = None,
|
|
246
|
+
domains: Optional[List[str]] = None,
|
|
247
|
+
memory_top_k: Optional[int] = None,
|
|
248
|
+
original_top_k: Optional[int] = None,
|
|
249
|
+
include_original_chunks: bool = True,
|
|
250
|
+
search_mode: str = "default",
|
|
251
|
+
top_k: Optional[int] = None,
|
|
252
|
+
minimum_score: float = 0.0,
|
|
253
|
+
) -> HybridSearchResult:
|
|
254
|
+
"""Deprecated alias for :meth:`search_v2`.
|
|
255
|
+
|
|
256
|
+
The method remains source-compatible but now uses the unified
|
|
257
|
+
``/v2/memory/search`` endpoint.
|
|
258
|
+
"""
|
|
259
|
+
return self.search_v2(
|
|
260
|
+
query=query,
|
|
261
|
+
user_id=user_id,
|
|
262
|
+
domains=domains,
|
|
263
|
+
memory_top_k=memory_top_k,
|
|
264
|
+
original_top_k=original_top_k,
|
|
265
|
+
include_original_chunks=include_original_chunks,
|
|
266
|
+
search_mode=search_mode,
|
|
267
|
+
top_k=top_k,
|
|
268
|
+
minimum_score=minimum_score,
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
def search_v2(
|
|
272
|
+
self,
|
|
273
|
+
query: str,
|
|
274
|
+
user_id: Optional[str] = None,
|
|
275
|
+
domains: Optional[List[str]] = None,
|
|
276
|
+
memory_top_k: Optional[int] = None,
|
|
277
|
+
original_top_k: Optional[int] = None,
|
|
278
|
+
include_original_chunks: bool = True,
|
|
279
|
+
search_mode: str = "default",
|
|
280
|
+
top_k: Optional[int] = None,
|
|
281
|
+
minimum_score: float = 0.0,
|
|
282
|
+
) -> HybridSearchResult:
|
|
283
|
+
"""Search personal v2 memory and original chunks through one route.
|
|
284
|
+
|
|
285
|
+
The authenticated credential determines the personal user. ``user_id``
|
|
286
|
+
and ``memory_top_k`` are deprecated compatibility arguments.
|
|
287
|
+
"""
|
|
288
|
+
payload = _personal_v2_search_payload(
|
|
289
|
+
query=query,
|
|
290
|
+
user_id=user_id,
|
|
291
|
+
domains=domains,
|
|
292
|
+
memory_top_k=memory_top_k,
|
|
293
|
+
original_top_k=original_top_k,
|
|
294
|
+
include_original_chunks=include_original_chunks,
|
|
295
|
+
search_mode=search_mode,
|
|
296
|
+
top_k=top_k,
|
|
297
|
+
minimum_score=minimum_score,
|
|
298
|
+
)
|
|
299
|
+
env = self._transport.post("/v2/memory/search", json=payload)
|
|
300
|
+
return _parse_hybrid_search(env.data or {}, env.request_id, env.elapsed_ms)
|
|
301
|
+
|
|
302
|
+
# ── Lifecycle ──────────────────────────────────────────────────────
|
|
303
|
+
|
|
304
|
+
def close(self) -> None:
|
|
305
|
+
"""Release the underlying HTTP connection pool."""
|
|
306
|
+
self._transport.close()
|
|
307
|
+
|
|
308
|
+
def __enter__(self) -> "MemcodeClient":
|
|
309
|
+
return self
|
|
310
|
+
|
|
311
|
+
def __exit__(self, *exc) -> None:
|
|
312
|
+
self.close()
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
316
|
+
# Response parsers (shared with async client)
|
|
317
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
318
|
+
|
|
319
|
+
def _parse_domain(raw: Optional[Dict[str, Any]]) -> Optional[DomainResult]:
|
|
320
|
+
if not raw:
|
|
321
|
+
return None
|
|
322
|
+
ops = [
|
|
323
|
+
OperationDetail(
|
|
324
|
+
type=o.get("type", ""), content=o.get("content", ""), reason=o.get("reason", ""),
|
|
325
|
+
)
|
|
326
|
+
for o in raw.get("operations", [])
|
|
327
|
+
]
|
|
328
|
+
ws_raw = raw.get("weaver")
|
|
329
|
+
ws = WeaverSummary(**ws_raw) if ws_raw else None
|
|
330
|
+
return DomainResult(confidence=raw.get("confidence", 0.0), operations=ops, weaver=ws)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _parse_sources(raw: List[Dict[str, Any]]) -> List[SourceRecord]:
|
|
334
|
+
return [
|
|
335
|
+
SourceRecord(
|
|
336
|
+
domain=s.get("domain", ""),
|
|
337
|
+
content=s.get("content", ""),
|
|
338
|
+
score=s.get("score", 0.0),
|
|
339
|
+
metadata=s.get("metadata", {}),
|
|
340
|
+
)
|
|
341
|
+
for s in raw
|
|
342
|
+
]
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _parse_ingest(
|
|
346
|
+
data: Dict[str, Any], request_id: Optional[str], elapsed_ms: Optional[float],
|
|
347
|
+
) -> IngestResult:
|
|
348
|
+
return IngestResult(
|
|
349
|
+
model=data.get("model", ""),
|
|
350
|
+
classification=data.get("classification", []),
|
|
351
|
+
profile=_parse_domain(data.get("profile")),
|
|
352
|
+
temporal=_parse_domain(data.get("temporal")),
|
|
353
|
+
summary=_parse_domain(data.get("summary")),
|
|
354
|
+
image=_parse_domain(data.get("image")),
|
|
355
|
+
request_id=request_id,
|
|
356
|
+
elapsed_ms=elapsed_ms,
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _required_personal(name: str, value: str) -> str:
|
|
361
|
+
normalized = value.strip() if isinstance(value, str) else ""
|
|
362
|
+
if not normalized:
|
|
363
|
+
raise ValueError(f"MemcodeClient: {name} is required")
|
|
364
|
+
return normalized
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _append_legacy_user_id(
|
|
368
|
+
payload: Dict[str, Any],
|
|
369
|
+
user_id: Optional[str],
|
|
370
|
+
*,
|
|
371
|
+
stacklevel: int = 3,
|
|
372
|
+
) -> None:
|
|
373
|
+
"""Append the deprecated personal identifier only when explicitly supplied."""
|
|
374
|
+
if user_id is not None:
|
|
375
|
+
warnings.warn(
|
|
376
|
+
"personal v2 user_id is deprecated; identity is derived from authentication",
|
|
377
|
+
DeprecationWarning,
|
|
378
|
+
stacklevel=stacklevel,
|
|
379
|
+
)
|
|
380
|
+
payload["user_id"] = _required_personal("user_id", user_id)
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _personal_v2_ingest_payload(
|
|
384
|
+
user_query: str,
|
|
385
|
+
user_id: Optional[str],
|
|
386
|
+
agent_response: str,
|
|
387
|
+
session_datetime: str,
|
|
388
|
+
image_url: str,
|
|
389
|
+
effort_level: str,
|
|
390
|
+
forget: bool,
|
|
391
|
+
) -> Dict[str, Any]:
|
|
392
|
+
if effort_level not in {"low", "high"}:
|
|
393
|
+
raise ValueError("MemcodeClient: effort_level must be low or high")
|
|
394
|
+
payload: Dict[str, Any] = {
|
|
395
|
+
"user_query": _required_personal("user_query", user_query),
|
|
396
|
+
"agent_response": agent_response,
|
|
397
|
+
"session_datetime": session_datetime,
|
|
398
|
+
"image_url": image_url,
|
|
399
|
+
"effort_level": effort_level,
|
|
400
|
+
"forget": forget,
|
|
401
|
+
}
|
|
402
|
+
_append_legacy_user_id(payload, user_id, stacklevel=4)
|
|
403
|
+
return payload
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _optional_idempotency_headers(value: str) -> Dict[str, str]:
|
|
407
|
+
if not value:
|
|
408
|
+
return {}
|
|
409
|
+
key = _required_personal("idempotency_key", value)
|
|
410
|
+
if len(key) > 256:
|
|
411
|
+
raise ValueError("MemcodeClient: idempotency_key cannot exceed 256 characters")
|
|
412
|
+
return {"Idempotency-Key": key}
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _parse_personal_v2_ingest(
|
|
416
|
+
data: Dict[str, Any], request_id: Optional[str], elapsed_ms: Optional[float],
|
|
417
|
+
) -> PersonalV2IngestResult:
|
|
418
|
+
return PersonalV2IngestResult(
|
|
419
|
+
job_id=data.get("job_id", ""),
|
|
420
|
+
status=data.get("status", ""),
|
|
421
|
+
created=data.get("created", False),
|
|
422
|
+
status_url=data.get("status_url", ""),
|
|
423
|
+
plan_id=data.get("plan_id"),
|
|
424
|
+
queued_for_batch=data.get("queued_for_batch"),
|
|
425
|
+
estimated_available_in_seconds=data.get("estimated_available_in_seconds"),
|
|
426
|
+
request_id=request_id,
|
|
427
|
+
elapsed_ms=elapsed_ms,
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def _parse_personal_v2_status(
|
|
432
|
+
data: Dict[str, Any], request_id: Optional[str], elapsed_ms: Optional[float],
|
|
433
|
+
) -> PersonalV2IngestStatus:
|
|
434
|
+
allowed = set(PersonalV2IngestStatus.__dataclass_fields__) - {
|
|
435
|
+
"request_id",
|
|
436
|
+
"elapsed_ms",
|
|
437
|
+
}
|
|
438
|
+
parsed = {key: value for key, value in data.items() if key in allowed}
|
|
439
|
+
return PersonalV2IngestStatus(
|
|
440
|
+
**parsed,
|
|
441
|
+
request_id=request_id,
|
|
442
|
+
elapsed_ms=elapsed_ms,
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _parse_retrieve(
|
|
447
|
+
data: Dict[str, Any], request_id: Optional[str], elapsed_ms: Optional[float],
|
|
448
|
+
) -> RetrieveResult:
|
|
449
|
+
return RetrieveResult(
|
|
450
|
+
model=data.get("model", ""),
|
|
451
|
+
answer=data.get("answer", ""),
|
|
452
|
+
sources=_parse_sources(data.get("sources", [])),
|
|
453
|
+
confidence=data.get("confidence", 0.0),
|
|
454
|
+
request_id=request_id,
|
|
455
|
+
elapsed_ms=elapsed_ms,
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _parse_personal_v2_retrieve(
|
|
460
|
+
data: Dict[str, Any], request_id: Optional[str], elapsed_ms: Optional[float],
|
|
461
|
+
) -> PersonalV2RetrieveResult:
|
|
462
|
+
return PersonalV2RetrieveResult(
|
|
463
|
+
model=data.get("model", ""),
|
|
464
|
+
answer=data.get("answer", ""),
|
|
465
|
+
sources=_parse_sources(data.get("sources", [])),
|
|
466
|
+
confidence=data.get("confidence", 0.0),
|
|
467
|
+
used_source_indices=data.get("used_source_indices", []),
|
|
468
|
+
connection_learning_queued=data.get("connection_learning_queued", False),
|
|
469
|
+
used_refs=data.get("used_refs", []),
|
|
470
|
+
billing=data.get("billing"),
|
|
471
|
+
request_id=request_id,
|
|
472
|
+
elapsed_ms=elapsed_ms,
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def _parse_search(
|
|
477
|
+
data: Dict[str, Any], request_id: Optional[str], elapsed_ms: Optional[float],
|
|
478
|
+
) -> SearchResult:
|
|
479
|
+
return SearchResult(
|
|
480
|
+
results=_parse_sources(data.get("results", [])),
|
|
481
|
+
total=data.get("total", 0),
|
|
482
|
+
request_id=request_id,
|
|
483
|
+
elapsed_ms=elapsed_ms,
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def _personal_v2_search_payload(
|
|
488
|
+
*,
|
|
489
|
+
query: str,
|
|
490
|
+
user_id: Optional[str],
|
|
491
|
+
domains: Optional[List[str]],
|
|
492
|
+
memory_top_k: Optional[int],
|
|
493
|
+
original_top_k: Optional[int],
|
|
494
|
+
include_original_chunks: bool,
|
|
495
|
+
search_mode: str,
|
|
496
|
+
top_k: Optional[int],
|
|
497
|
+
minimum_score: float,
|
|
498
|
+
) -> Dict[str, Any]:
|
|
499
|
+
normalized_query = query.strip() if isinstance(query, str) else ""
|
|
500
|
+
if not normalized_query:
|
|
501
|
+
raise ValueError("MemcodeClient: query is required")
|
|
502
|
+
if search_mode not in {"default", "global"}:
|
|
503
|
+
raise ValueError("MemcodeClient: search_mode must be default or global")
|
|
504
|
+
if (
|
|
505
|
+
isinstance(minimum_score, bool)
|
|
506
|
+
or not isinstance(minimum_score, (int, float))
|
|
507
|
+
or not 0 <= minimum_score <= 1
|
|
508
|
+
):
|
|
509
|
+
raise ValueError("MemcodeClient: minimum_score must be between 0 and 1")
|
|
510
|
+
if not isinstance(include_original_chunks, bool):
|
|
511
|
+
raise ValueError("MemcodeClient: include_original_chunks must be a boolean")
|
|
512
|
+
|
|
513
|
+
payload: Dict[str, Any] = {
|
|
514
|
+
"query": normalized_query,
|
|
515
|
+
"search_mode": search_mode,
|
|
516
|
+
"include_original_chunks": include_original_chunks,
|
|
517
|
+
"minimum_score": minimum_score,
|
|
518
|
+
}
|
|
519
|
+
_append_legacy_user_id(payload, user_id, stacklevel=4)
|
|
520
|
+
if domains is not None:
|
|
521
|
+
if not domains:
|
|
522
|
+
raise ValueError("MemcodeClient: domains cannot be empty")
|
|
523
|
+
allowed = {"profile", "temporal", "summary", "original_chunk"}
|
|
524
|
+
if any(domain not in allowed for domain in domains):
|
|
525
|
+
raise ValueError(
|
|
526
|
+
"MemcodeClient: domains must contain only profile, temporal, "
|
|
527
|
+
"summary, or original_chunk"
|
|
528
|
+
)
|
|
529
|
+
payload["domains"] = domains
|
|
530
|
+
for name, value in (
|
|
531
|
+
("top_k", top_k),
|
|
532
|
+
("memory_top_k", memory_top_k),
|
|
533
|
+
("original_top_k", original_top_k),
|
|
534
|
+
):
|
|
535
|
+
if value is not None:
|
|
536
|
+
if isinstance(value, bool) or not isinstance(value, int) or not 1 <= value <= 100:
|
|
537
|
+
raise ValueError(f"MemcodeClient: {name} must be an integer between 1 and 100")
|
|
538
|
+
if top_k is not None and memory_top_k is not None:
|
|
539
|
+
raise ValueError("MemcodeClient: provide only one of top_k or memory_top_k")
|
|
540
|
+
payload["top_k"] = (
|
|
541
|
+
top_k
|
|
542
|
+
if top_k is not None
|
|
543
|
+
else (memory_top_k if memory_top_k is not None else 10)
|
|
544
|
+
)
|
|
545
|
+
if original_top_k is not None:
|
|
546
|
+
payload["original_top_k"] = original_top_k
|
|
547
|
+
return payload
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def _parse_hybrid_search(
|
|
551
|
+
data: Dict[str, Any], request_id: Optional[str], elapsed_ms: Optional[float],
|
|
552
|
+
) -> HybridSearchResult:
|
|
553
|
+
has_flat_results = "results" in data
|
|
554
|
+
results = _parse_sources(data.get("results", []))
|
|
555
|
+
if "memory_results" in data:
|
|
556
|
+
memory_results = _parse_sources(data.get("memory_results", []))
|
|
557
|
+
else:
|
|
558
|
+
memory_results = [item for item in results if item.domain != "original_chunk"]
|
|
559
|
+
if "original_chunks" in data:
|
|
560
|
+
original_chunks = _parse_sources(data.get("original_chunks", []))
|
|
561
|
+
else:
|
|
562
|
+
original_chunks = [item for item in results if item.domain == "original_chunk"]
|
|
563
|
+
if not has_flat_results:
|
|
564
|
+
results = memory_results + original_chunks
|
|
565
|
+
return HybridSearchResult(
|
|
566
|
+
memory_results=memory_results,
|
|
567
|
+
original_chunks=original_chunks,
|
|
568
|
+
results=results,
|
|
569
|
+
total=data.get("total", len(results)),
|
|
570
|
+
original_storage_enabled=data.get("original_storage_enabled", False),
|
|
571
|
+
failed_domains=data.get("failed_domains", []),
|
|
572
|
+
partial=data.get("partial", False),
|
|
573
|
+
billing=data.get("billing"),
|
|
574
|
+
request_id=request_id,
|
|
575
|
+
elapsed_ms=elapsed_ms,
|
|
576
|
+
)
|
memcode_sdk/errors.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Memcode SDK error hierarchy.
|
|
3
|
+
|
|
4
|
+
All SDK-raised exceptions inherit from ``MemcodeSDKError`` so callers
|
|
5
|
+
can catch the full family with a single ``except MemcodeSDKError``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any, Dict, Optional
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class MemcodeSDKError(Exception):
|
|
14
|
+
"""Base for every error raised by the Memcode client SDK."""
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
message: str,
|
|
19
|
+
*,
|
|
20
|
+
status_code: Optional[int] = None,
|
|
21
|
+
request_id: Optional[str] = None,
|
|
22
|
+
details: Optional[Dict[str, Any]] = None,
|
|
23
|
+
) -> None:
|
|
24
|
+
super().__init__(message)
|
|
25
|
+
self.message = message
|
|
26
|
+
self.status_code = status_code
|
|
27
|
+
self.request_id = request_id
|
|
28
|
+
self.details = details or {}
|
|
29
|
+
|
|
30
|
+
def __repr__(self) -> str:
|
|
31
|
+
parts = [f"message={self.message!r}"]
|
|
32
|
+
if self.status_code is not None:
|
|
33
|
+
parts.append(f"status_code={self.status_code}")
|
|
34
|
+
if self.request_id:
|
|
35
|
+
parts.append(f"request_id={self.request_id!r}")
|
|
36
|
+
return f"{self.__class__.__name__}({', '.join(parts)})"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class AuthenticationError(MemcodeSDKError):
|
|
40
|
+
"""401 / 403 — invalid or missing API key."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class RateLimitError(MemcodeSDKError):
|
|
44
|
+
"""429 — caller exceeded their per-key quota."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, message: str, *, retry_after: Optional[int] = None, **kwargs):
|
|
47
|
+
super().__init__(message, **kwargs)
|
|
48
|
+
self.retry_after = retry_after
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class ServerError(MemcodeSDKError):
|
|
52
|
+
"""5xx — the Memcode API returned a server-side failure."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class ValidationError(MemcodeSDKError):
|
|
56
|
+
"""400 / 422 — the request failed server-side validation."""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ConnectionError(MemcodeSDKError):
|
|
60
|
+
"""Network-level failure (timeout, DNS, refused, etc.)."""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class NotReadyError(MemcodeSDKError):
|
|
64
|
+
"""503 — the Memcode API pipelines are still loading."""
|
memcode_sdk/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|