cortexdbai 0.2.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.
- cortexdb/__init__.py +79 -0
- cortexdb/client.py +962 -0
- cortexdb/exceptions.py +69 -0
- cortexdb/models.py +390 -0
- cortexdb/py.typed +0 -0
- cortexdbai-0.2.0.dist-info/METADATA +236 -0
- cortexdbai-0.2.0.dist-info/RECORD +8 -0
- cortexdbai-0.2.0.dist-info/WHEEL +4 -0
cortexdb/client.py
ADDED
|
@@ -0,0 +1,962 @@
|
|
|
1
|
+
"""Synchronous and asynchronous HTTP clients for the CortexDB API.
|
|
2
|
+
|
|
3
|
+
Usage (sync)::
|
|
4
|
+
|
|
5
|
+
with Cortex() as client:
|
|
6
|
+
resp = client.remember("The deploy succeeded at 14:32 UTC.")
|
|
7
|
+
results = client.recall("What happened with the deploy?")
|
|
8
|
+
|
|
9
|
+
Usage (async)::
|
|
10
|
+
|
|
11
|
+
async with AsyncCortex() as client:
|
|
12
|
+
resp = await client.remember("The deploy succeeded at 14:32 UTC.")
|
|
13
|
+
results = await client.recall("What happened with the deploy?")
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import os
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
import httpx
|
|
22
|
+
from tenacity import (
|
|
23
|
+
retry,
|
|
24
|
+
retry_if_exception,
|
|
25
|
+
stop_after_attempt,
|
|
26
|
+
wait_exponential,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
from cortexdb.exceptions import (
|
|
30
|
+
CortexAPIError,
|
|
31
|
+
CortexAuthError,
|
|
32
|
+
CortexConnectionError,
|
|
33
|
+
CortexRateLimitError,
|
|
34
|
+
CortexTimeoutError,
|
|
35
|
+
)
|
|
36
|
+
from cortexdb.models import (
|
|
37
|
+
EntityResponse,
|
|
38
|
+
ForgetResponse,
|
|
39
|
+
HealthResponse,
|
|
40
|
+
IngestEpisodeRequest,
|
|
41
|
+
IngestEpisodeResponse,
|
|
42
|
+
LinkResponse,
|
|
43
|
+
ListEpisodesResponse,
|
|
44
|
+
MetricsResponse,
|
|
45
|
+
RecallResponse,
|
|
46
|
+
RememberResponse,
|
|
47
|
+
SearchResponse,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
_DEFAULT_BASE_URL = "https://api.cortexdb.ai"
|
|
51
|
+
_RETRYABLE_STATUS_CODES = {429, 502, 503, 504}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _is_retryable(exc: BaseException) -> bool:
|
|
55
|
+
"""Return True if the exception warrants a retry."""
|
|
56
|
+
if isinstance(exc, CortexRateLimitError):
|
|
57
|
+
return True
|
|
58
|
+
if isinstance(exc, CortexAPIError) and exc.status_code in _RETRYABLE_STATUS_CODES:
|
|
59
|
+
return True
|
|
60
|
+
if isinstance(exc, CortexConnectionError):
|
|
61
|
+
return True
|
|
62
|
+
return False
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _raise_for_status(response: httpx.Response) -> None:
|
|
66
|
+
"""Translate an HTTP error response into the appropriate CortexDB exception."""
|
|
67
|
+
if response.is_success:
|
|
68
|
+
return
|
|
69
|
+
|
|
70
|
+
status = response.status_code
|
|
71
|
+
try:
|
|
72
|
+
body = response.json()
|
|
73
|
+
except Exception:
|
|
74
|
+
body = {}
|
|
75
|
+
|
|
76
|
+
message = body.get("message") or body.get("error") or response.reason_phrase or "Unknown error"
|
|
77
|
+
error_code = body.get("error_code") or body.get("code")
|
|
78
|
+
|
|
79
|
+
if status == 429:
|
|
80
|
+
retry_after_raw = response.headers.get("Retry-After")
|
|
81
|
+
retry_after: float | None = None
|
|
82
|
+
if retry_after_raw is not None:
|
|
83
|
+
try:
|
|
84
|
+
retry_after = float(retry_after_raw)
|
|
85
|
+
except (ValueError, TypeError):
|
|
86
|
+
pass
|
|
87
|
+
raise CortexRateLimitError(
|
|
88
|
+
message=message,
|
|
89
|
+
status_code=status,
|
|
90
|
+
error_code=error_code,
|
|
91
|
+
retry_after=retry_after,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
if status in (401, 403):
|
|
95
|
+
raise CortexAuthError(message=message, status_code=status, error_code=error_code)
|
|
96
|
+
|
|
97
|
+
if status >= 400:
|
|
98
|
+
raise CortexAPIError(message=message, status_code=status, error_code=error_code)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _build_headers(api_key: str | None) -> dict[str, str]:
|
|
102
|
+
headers: dict[str, str] = {
|
|
103
|
+
"User-Agent": "cortexdb-python/0.2.0",
|
|
104
|
+
"Accept": "application/json",
|
|
105
|
+
}
|
|
106
|
+
if api_key:
|
|
107
|
+
headers["Authorization"] = f"Bearer {api_key}"
|
|
108
|
+
return headers
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# ---------------------------------------------------------------------------
|
|
112
|
+
# Synchronous client
|
|
113
|
+
# ---------------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class Cortex:
|
|
117
|
+
"""Synchronous client for the CortexDB HTTP API.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
base_url: Root URL of the CortexDB server (default ``https://api.cortexdb.ai``).
|
|
121
|
+
api_key: Bearer token for authentication. Falls back to the
|
|
122
|
+
``CORTEXDB_API_KEY`` environment variable when *None*.
|
|
123
|
+
timeout: Request timeout in seconds.
|
|
124
|
+
max_retries: Maximum number of retry attempts for transient failures.
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
def __init__(
|
|
128
|
+
self,
|
|
129
|
+
base_url: str = _DEFAULT_BASE_URL,
|
|
130
|
+
api_key: str | None = None,
|
|
131
|
+
timeout: float = 30.0,
|
|
132
|
+
max_retries: int = 3,
|
|
133
|
+
) -> None:
|
|
134
|
+
self._base_url = base_url.rstrip("/")
|
|
135
|
+
self._api_key = api_key or os.environ.get("CORTEXDB_API_KEY")
|
|
136
|
+
self._max_retries = max_retries
|
|
137
|
+
self._client = httpx.Client(
|
|
138
|
+
base_url=self._base_url,
|
|
139
|
+
headers=_build_headers(self._api_key),
|
|
140
|
+
timeout=timeout,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
# -- context manager -----------------------------------------------------
|
|
144
|
+
|
|
145
|
+
def __enter__(self) -> Cortex:
|
|
146
|
+
return self
|
|
147
|
+
|
|
148
|
+
def __exit__(self, *exc: object) -> None:
|
|
149
|
+
self.close()
|
|
150
|
+
|
|
151
|
+
def close(self) -> None:
|
|
152
|
+
"""Close the underlying HTTP connection pool."""
|
|
153
|
+
self._client.close()
|
|
154
|
+
|
|
155
|
+
# -- public API ----------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
def remember(
|
|
158
|
+
self,
|
|
159
|
+
content: str,
|
|
160
|
+
tenant_id: str = "default",
|
|
161
|
+
scope: str | None = None,
|
|
162
|
+
metadata: dict[str, Any] | None = None,
|
|
163
|
+
) -> RememberResponse:
|
|
164
|
+
"""Store a piece of information in CortexDB.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
content: The text content to remember.
|
|
168
|
+
tenant_id: Tenant namespace.
|
|
169
|
+
scope: Optional scope qualifier.
|
|
170
|
+
metadata: Arbitrary key-value metadata to attach.
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
A ``RememberResponse`` with the ID assigned by the server.
|
|
174
|
+
"""
|
|
175
|
+
payload: dict[str, Any] = {"content": content, "tenant_id": tenant_id}
|
|
176
|
+
if scope is not None:
|
|
177
|
+
payload["scope"] = scope
|
|
178
|
+
if metadata is not None:
|
|
179
|
+
payload["metadata"] = metadata
|
|
180
|
+
data = self._request("POST", "/v1/remember", json=payload)
|
|
181
|
+
return RememberResponse.model_validate(data)
|
|
182
|
+
|
|
183
|
+
def recall(
|
|
184
|
+
self,
|
|
185
|
+
query: str,
|
|
186
|
+
tenant_id: str = "default",
|
|
187
|
+
max_tokens: int = 4096,
|
|
188
|
+
min_confidence: float = 0.0,
|
|
189
|
+
) -> RecallResponse:
|
|
190
|
+
"""Retrieve relevant memories matching a query.
|
|
191
|
+
|
|
192
|
+
Args:
|
|
193
|
+
query: Natural-language query.
|
|
194
|
+
tenant_id: Tenant namespace.
|
|
195
|
+
max_tokens: Maximum token budget for the response.
|
|
196
|
+
min_confidence: Minimum confidence threshold for matches.
|
|
197
|
+
|
|
198
|
+
Returns:
|
|
199
|
+
A ``RecallResponse`` containing ranked matches.
|
|
200
|
+
"""
|
|
201
|
+
payload: dict[str, Any] = {
|
|
202
|
+
"query": query,
|
|
203
|
+
"tenant_id": tenant_id,
|
|
204
|
+
}
|
|
205
|
+
data = self._request("POST", "/v1/recall", json=payload)
|
|
206
|
+
return RecallResponse.model_validate(data)
|
|
207
|
+
|
|
208
|
+
def forget(
|
|
209
|
+
self,
|
|
210
|
+
query: str | None = None,
|
|
211
|
+
reason: str = "",
|
|
212
|
+
tenant_id: str = "default",
|
|
213
|
+
) -> ForgetResponse:
|
|
214
|
+
"""Delete memories matching a query.
|
|
215
|
+
|
|
216
|
+
Args:
|
|
217
|
+
query: Pattern or identifier of memories to delete. When *None*,
|
|
218
|
+
the server determines the scope.
|
|
219
|
+
reason: Human-readable justification for the deletion.
|
|
220
|
+
tenant_id: Tenant namespace.
|
|
221
|
+
|
|
222
|
+
Returns:
|
|
223
|
+
A ``ForgetResponse`` indicating how many records were deleted.
|
|
224
|
+
"""
|
|
225
|
+
payload: dict[str, Any] = {"reason": reason, "tenant_id": tenant_id}
|
|
226
|
+
if query is not None:
|
|
227
|
+
payload["query"] = query
|
|
228
|
+
data = self._request("POST", "/v1/forget", json=payload)
|
|
229
|
+
return ForgetResponse.model_validate(data)
|
|
230
|
+
|
|
231
|
+
def ingest_episode(self, episode: IngestEpisodeRequest) -> IngestEpisodeResponse:
|
|
232
|
+
"""Ingest a structured episode into CortexDB.
|
|
233
|
+
|
|
234
|
+
Args:
|
|
235
|
+
episode: The episode to ingest.
|
|
236
|
+
|
|
237
|
+
Returns:
|
|
238
|
+
An ``IngestEpisodeResponse`` with the episode ID and status.
|
|
239
|
+
"""
|
|
240
|
+
payload = episode.model_dump(
|
|
241
|
+
by_alias=False, exclude_none=True, exclude={"tenant_id"}
|
|
242
|
+
)
|
|
243
|
+
data = self._request("POST", "/v1/episodes", json=payload)
|
|
244
|
+
return IngestEpisodeResponse.model_validate(data)
|
|
245
|
+
|
|
246
|
+
def list_episodes(
|
|
247
|
+
self,
|
|
248
|
+
tenant_id: str = "default",
|
|
249
|
+
offset: int = 0,
|
|
250
|
+
limit: int = 50,
|
|
251
|
+
**kwargs: Any,
|
|
252
|
+
) -> ListEpisodesResponse:
|
|
253
|
+
"""List episodes with optional filtering.
|
|
254
|
+
|
|
255
|
+
Args:
|
|
256
|
+
tenant_id: Tenant namespace.
|
|
257
|
+
offset: Pagination offset.
|
|
258
|
+
limit: Maximum number of episodes to return.
|
|
259
|
+
**kwargs: Additional query parameters forwarded to the API
|
|
260
|
+
(e.g. ``type``, ``actor_id``).
|
|
261
|
+
|
|
262
|
+
Returns:
|
|
263
|
+
A ``ListEpisodesResponse`` with the episode list and pagination info.
|
|
264
|
+
"""
|
|
265
|
+
params: dict[str, Any] = {
|
|
266
|
+
"tenant_id": tenant_id,
|
|
267
|
+
"offset": offset,
|
|
268
|
+
"limit": limit,
|
|
269
|
+
**kwargs,
|
|
270
|
+
}
|
|
271
|
+
data = self._request("GET", "/v1/episodes", params=params)
|
|
272
|
+
return ListEpisodesResponse.model_validate(data)
|
|
273
|
+
|
|
274
|
+
def health(self) -> HealthResponse:
|
|
275
|
+
"""Check server health.
|
|
276
|
+
|
|
277
|
+
Returns:
|
|
278
|
+
A ``HealthResponse`` with the server's status and version.
|
|
279
|
+
"""
|
|
280
|
+
data = self._request("GET", "/v1/admin/health")
|
|
281
|
+
return HealthResponse.model_validate(data)
|
|
282
|
+
|
|
283
|
+
def metrics(self) -> MetricsResponse:
|
|
284
|
+
"""Retrieve server metrics.
|
|
285
|
+
|
|
286
|
+
Returns:
|
|
287
|
+
A ``MetricsResponse`` with episode counts and storage usage.
|
|
288
|
+
"""
|
|
289
|
+
data = self._request("GET", "/v1/admin/metrics")
|
|
290
|
+
return MetricsResponse.model_validate(data)
|
|
291
|
+
|
|
292
|
+
def entity(self, entity_id: str, tenant_id: str = "default") -> EntityResponse:
|
|
293
|
+
"""Get detailed information about a specific entity.
|
|
294
|
+
|
|
295
|
+
Returns entity metadata, relationships, and recent episodes.
|
|
296
|
+
|
|
297
|
+
Args:
|
|
298
|
+
entity_id: The entity ID to look up.
|
|
299
|
+
tenant_id: Tenant namespace.
|
|
300
|
+
|
|
301
|
+
Returns:
|
|
302
|
+
An ``EntityResponse`` with entity details, relationships, and episodes.
|
|
303
|
+
"""
|
|
304
|
+
data = self._request(
|
|
305
|
+
"GET",
|
|
306
|
+
f"/v1/entities/{entity_id}",
|
|
307
|
+
headers={"x-tenant-id": tenant_id},
|
|
308
|
+
)
|
|
309
|
+
return EntityResponse.model_validate(data)
|
|
310
|
+
|
|
311
|
+
def link(
|
|
312
|
+
self,
|
|
313
|
+
source_entity_id: str,
|
|
314
|
+
target_entity_id: str,
|
|
315
|
+
relationship: str,
|
|
316
|
+
fact: str = "",
|
|
317
|
+
confidence: float = 0.9,
|
|
318
|
+
tenant_id: str = "default",
|
|
319
|
+
) -> LinkResponse:
|
|
320
|
+
"""Create an explicit relationship between two entities.
|
|
321
|
+
|
|
322
|
+
Args:
|
|
323
|
+
source_entity_id: ID of the source entity.
|
|
324
|
+
target_entity_id: ID of the target entity.
|
|
325
|
+
relationship: Relationship type (e.g., ``"DEPENDS_ON"``, ``"OWNS"``).
|
|
326
|
+
fact: Human-readable description of the relationship.
|
|
327
|
+
confidence: Confidence score (0.0 to 1.0).
|
|
328
|
+
tenant_id: Tenant namespace.
|
|
329
|
+
|
|
330
|
+
Returns:
|
|
331
|
+
A ``LinkResponse`` with the created edge ID.
|
|
332
|
+
"""
|
|
333
|
+
payload = {
|
|
334
|
+
"source_entity_id": source_entity_id,
|
|
335
|
+
"target_entity_id": target_entity_id,
|
|
336
|
+
"relationship": relationship,
|
|
337
|
+
"fact": fact,
|
|
338
|
+
"confidence": confidence,
|
|
339
|
+
}
|
|
340
|
+
data = self._request(
|
|
341
|
+
"POST",
|
|
342
|
+
"/v1/link",
|
|
343
|
+
json=payload,
|
|
344
|
+
headers={"x-tenant-id": tenant_id},
|
|
345
|
+
)
|
|
346
|
+
return LinkResponse.model_validate(data)
|
|
347
|
+
|
|
348
|
+
def search(
|
|
349
|
+
self,
|
|
350
|
+
query: str,
|
|
351
|
+
source: str | None = None,
|
|
352
|
+
episode_type: str | None = None,
|
|
353
|
+
time_range: str | None = None,
|
|
354
|
+
namespace: str | None = None,
|
|
355
|
+
limit: int = 20,
|
|
356
|
+
offset: int = 0,
|
|
357
|
+
tenant_id: str = "default",
|
|
358
|
+
) -> SearchResponse:
|
|
359
|
+
"""Search across memories and episodes with filters.
|
|
360
|
+
|
|
361
|
+
Unlike :meth:`recall`, this returns both context and matching episodes.
|
|
362
|
+
|
|
363
|
+
Args:
|
|
364
|
+
query: Natural-language search query.
|
|
365
|
+
source: Filter by source connector (e.g., ``"slack"``, ``"github"``).
|
|
366
|
+
episode_type: Filter by episode type (e.g., ``"message"``, ``"incident"``).
|
|
367
|
+
time_range: Time range filter (e.g., ``"1h"``, ``"24h"``, ``"7d"``, ``"30d"``).
|
|
368
|
+
namespace: Filter by namespace.
|
|
369
|
+
limit: Maximum results to return.
|
|
370
|
+
offset: Pagination offset.
|
|
371
|
+
tenant_id: Tenant namespace.
|
|
372
|
+
|
|
373
|
+
Returns:
|
|
374
|
+
A ``SearchResponse`` with context, episodes, and metadata.
|
|
375
|
+
"""
|
|
376
|
+
payload: dict[str, Any] = {"query": query, "limit": limit, "offset": offset}
|
|
377
|
+
filters: dict[str, str] = {}
|
|
378
|
+
if source:
|
|
379
|
+
filters["source"] = source
|
|
380
|
+
if episode_type:
|
|
381
|
+
filters["episode_type"] = episode_type
|
|
382
|
+
if time_range:
|
|
383
|
+
filters["time_range"] = time_range
|
|
384
|
+
if namespace:
|
|
385
|
+
filters["namespace"] = namespace
|
|
386
|
+
if filters:
|
|
387
|
+
payload["filters"] = filters
|
|
388
|
+
data = self._request(
|
|
389
|
+
"POST",
|
|
390
|
+
"/v1/search",
|
|
391
|
+
json=payload,
|
|
392
|
+
headers={"x-tenant-id": tenant_id},
|
|
393
|
+
)
|
|
394
|
+
return SearchResponse.model_validate(data)
|
|
395
|
+
|
|
396
|
+
# -- GTM convenience methods ---------------------------------------------
|
|
397
|
+
|
|
398
|
+
def memory(
|
|
399
|
+
self,
|
|
400
|
+
query: str,
|
|
401
|
+
tenant_id: str = "default",
|
|
402
|
+
**kwargs: Any,
|
|
403
|
+
) -> RecallResponse:
|
|
404
|
+
"""Recall memories with a single natural-language query.
|
|
405
|
+
|
|
406
|
+
This is the GTM one-liner::
|
|
407
|
+
|
|
408
|
+
cortex.memory("why did checkout fail yesterday")
|
|
409
|
+
|
|
410
|
+
It is an alias for :meth:`recall` with the same parameter semantics.
|
|
411
|
+
|
|
412
|
+
Args:
|
|
413
|
+
query: Natural-language query.
|
|
414
|
+
tenant_id: Tenant namespace.
|
|
415
|
+
**kwargs: Additional keyword arguments forwarded to :meth:`recall`
|
|
416
|
+
(e.g. ``max_tokens``, ``min_confidence``).
|
|
417
|
+
|
|
418
|
+
Returns:
|
|
419
|
+
A ``RecallResponse`` containing ranked matches.
|
|
420
|
+
"""
|
|
421
|
+
return self.recall(query=query, tenant_id=tenant_id, **kwargs)
|
|
422
|
+
|
|
423
|
+
def store(
|
|
424
|
+
self,
|
|
425
|
+
data: str | dict[str, Any],
|
|
426
|
+
tenant_id: str = "default",
|
|
427
|
+
**kwargs: Any,
|
|
428
|
+
) -> RememberResponse | IngestEpisodeResponse:
|
|
429
|
+
"""Smart store that accepts a string or a dict.
|
|
430
|
+
|
|
431
|
+
Usage::
|
|
432
|
+
|
|
433
|
+
cortex.store("The deploy succeeded at 14:32 UTC.")
|
|
434
|
+
cortex.store({"content": "deploy ok", "type": "event", "source": "ci"})
|
|
435
|
+
|
|
436
|
+
When *data* is a **string**, it delegates to :meth:`remember`.
|
|
437
|
+
When *data* is a **dict**, it converts it via
|
|
438
|
+
:meth:`IngestEpisodeRequest.from_dict` and delegates to
|
|
439
|
+
:meth:`ingest_episode`.
|
|
440
|
+
|
|
441
|
+
Args:
|
|
442
|
+
data: Either a plain string (stored via ``remember``) or a dict
|
|
443
|
+
describing a structured episode (stored via ``ingest_episode``).
|
|
444
|
+
tenant_id: Tenant namespace (used for both code paths).
|
|
445
|
+
**kwargs: Extra keyword arguments forwarded to ``remember`` when
|
|
446
|
+
*data* is a string.
|
|
447
|
+
|
|
448
|
+
Returns:
|
|
449
|
+
A ``RememberResponse`` (string path) or
|
|
450
|
+
``IngestEpisodeResponse`` (dict path).
|
|
451
|
+
|
|
452
|
+
Raises:
|
|
453
|
+
TypeError: If *data* is neither a string nor a dict.
|
|
454
|
+
"""
|
|
455
|
+
if isinstance(data, str):
|
|
456
|
+
return self.remember(content=data, tenant_id=tenant_id, **kwargs)
|
|
457
|
+
|
|
458
|
+
if isinstance(data, dict):
|
|
459
|
+
data.setdefault("tenant_id", tenant_id)
|
|
460
|
+
episode = IngestEpisodeRequest.from_dict(data)
|
|
461
|
+
return self.ingest_episode(episode)
|
|
462
|
+
|
|
463
|
+
raise TypeError(
|
|
464
|
+
f"store() expects a str or dict, got {type(data).__name__}"
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
def context(
|
|
468
|
+
self,
|
|
469
|
+
query: str,
|
|
470
|
+
agent_id: str = "default",
|
|
471
|
+
tenant_id: str = "default",
|
|
472
|
+
max_tokens: int = 8192,
|
|
473
|
+
) -> RecallResponse:
|
|
474
|
+
"""Retrieve enriched context for an LLM agent.
|
|
475
|
+
|
|
476
|
+
Designed for feeding context windows to autonomous agents. Uses a
|
|
477
|
+
higher default token budget than :meth:`recall`.
|
|
478
|
+
|
|
479
|
+
Usage::
|
|
480
|
+
|
|
481
|
+
cortex.context(agent_id="incident-bot", query="current status of payments")
|
|
482
|
+
|
|
483
|
+
Args:
|
|
484
|
+
query: Natural-language query describing what context is needed.
|
|
485
|
+
agent_id: Identifier of the requesting agent (reserved for
|
|
486
|
+
future per-agent retrieval policies).
|
|
487
|
+
tenant_id: Tenant namespace.
|
|
488
|
+
max_tokens: Maximum token budget for the response (default 8192).
|
|
489
|
+
|
|
490
|
+
Returns:
|
|
491
|
+
A ``RecallResponse`` containing ranked matches.
|
|
492
|
+
"""
|
|
493
|
+
return self.recall(
|
|
494
|
+
query=query,
|
|
495
|
+
tenant_id=tenant_id,
|
|
496
|
+
max_tokens=max_tokens,
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
# -- internal ------------------------------------------------------------
|
|
500
|
+
|
|
501
|
+
def _request(self, method: str, path: str, **kwargs: Any) -> Any:
|
|
502
|
+
"""Execute an HTTP request with retry logic.
|
|
503
|
+
|
|
504
|
+
Retries on 429, 502, 503, and 504 responses using exponential
|
|
505
|
+
backoff. On 429 responses the ``Retry-After`` header is respected.
|
|
506
|
+
|
|
507
|
+
Returns:
|
|
508
|
+
Decoded JSON response body.
|
|
509
|
+
|
|
510
|
+
Raises:
|
|
511
|
+
CortexAPIError: On non-retryable HTTP errors.
|
|
512
|
+
CortexConnectionError: When the server is unreachable.
|
|
513
|
+
CortexTimeoutError: When the request exceeds the configured timeout.
|
|
514
|
+
"""
|
|
515
|
+
|
|
516
|
+
@retry(
|
|
517
|
+
retry=retry_if_exception(_is_retryable),
|
|
518
|
+
stop=stop_after_attempt(self._max_retries),
|
|
519
|
+
wait=wait_exponential(multiplier=0.5, min=0.5, max=10),
|
|
520
|
+
reraise=True,
|
|
521
|
+
)
|
|
522
|
+
def _do() -> Any:
|
|
523
|
+
try:
|
|
524
|
+
response = self._client.request(method, path, **kwargs)
|
|
525
|
+
except httpx.TimeoutException as exc:
|
|
526
|
+
raise CortexTimeoutError(str(exc)) from exc
|
|
527
|
+
except httpx.ConnectError as exc:
|
|
528
|
+
raise CortexConnectionError(str(exc)) from exc
|
|
529
|
+
except httpx.HTTPError as exc:
|
|
530
|
+
raise CortexConnectionError(str(exc)) from exc
|
|
531
|
+
|
|
532
|
+
_raise_for_status(response)
|
|
533
|
+
|
|
534
|
+
if response.status_code == 204:
|
|
535
|
+
return {}
|
|
536
|
+
return response.json()
|
|
537
|
+
|
|
538
|
+
return _do()
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
# ---------------------------------------------------------------------------
|
|
542
|
+
# Asynchronous client
|
|
543
|
+
# ---------------------------------------------------------------------------
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
class AsyncCortex:
|
|
547
|
+
"""Asynchronous client for the CortexDB HTTP API.
|
|
548
|
+
|
|
549
|
+
Provides the same interface as :class:`Cortex` but all network methods are
|
|
550
|
+
coroutines suitable for use with ``asyncio``.
|
|
551
|
+
|
|
552
|
+
Args:
|
|
553
|
+
base_url: Root URL of the CortexDB server (default ``https://api.cortexdb.ai``).
|
|
554
|
+
api_key: Bearer token for authentication. Falls back to the
|
|
555
|
+
``CORTEXDB_API_KEY`` environment variable when *None*.
|
|
556
|
+
timeout: Request timeout in seconds.
|
|
557
|
+
max_retries: Maximum number of retry attempts for transient failures.
|
|
558
|
+
"""
|
|
559
|
+
|
|
560
|
+
def __init__(
|
|
561
|
+
self,
|
|
562
|
+
base_url: str = _DEFAULT_BASE_URL,
|
|
563
|
+
api_key: str | None = None,
|
|
564
|
+
timeout: float = 30.0,
|
|
565
|
+
max_retries: int = 3,
|
|
566
|
+
) -> None:
|
|
567
|
+
self._base_url = base_url.rstrip("/")
|
|
568
|
+
self._api_key = api_key or os.environ.get("CORTEXDB_API_KEY")
|
|
569
|
+
self._max_retries = max_retries
|
|
570
|
+
self._client = httpx.AsyncClient(
|
|
571
|
+
base_url=self._base_url,
|
|
572
|
+
headers=_build_headers(self._api_key),
|
|
573
|
+
timeout=timeout,
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
# -- context manager -----------------------------------------------------
|
|
577
|
+
|
|
578
|
+
async def __aenter__(self) -> AsyncCortex:
|
|
579
|
+
return self
|
|
580
|
+
|
|
581
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
582
|
+
await self.close()
|
|
583
|
+
|
|
584
|
+
async def close(self) -> None:
|
|
585
|
+
"""Close the underlying HTTP connection pool."""
|
|
586
|
+
await self._client.aclose()
|
|
587
|
+
|
|
588
|
+
# -- public API ----------------------------------------------------------
|
|
589
|
+
|
|
590
|
+
async def remember(
|
|
591
|
+
self,
|
|
592
|
+
content: str,
|
|
593
|
+
tenant_id: str = "default",
|
|
594
|
+
scope: str | None = None,
|
|
595
|
+
metadata: dict[str, Any] | None = None,
|
|
596
|
+
) -> RememberResponse:
|
|
597
|
+
"""Store a piece of information in CortexDB.
|
|
598
|
+
|
|
599
|
+
Args:
|
|
600
|
+
content: The text content to remember.
|
|
601
|
+
tenant_id: Tenant namespace.
|
|
602
|
+
scope: Optional scope qualifier.
|
|
603
|
+
metadata: Arbitrary key-value metadata to attach.
|
|
604
|
+
|
|
605
|
+
Returns:
|
|
606
|
+
A ``RememberResponse`` with the ID assigned by the server.
|
|
607
|
+
"""
|
|
608
|
+
payload: dict[str, Any] = {"content": content, "tenant_id": tenant_id}
|
|
609
|
+
if scope is not None:
|
|
610
|
+
payload["scope"] = scope
|
|
611
|
+
if metadata is not None:
|
|
612
|
+
payload["metadata"] = metadata
|
|
613
|
+
data = await self._request("POST", "/v1/remember", json=payload)
|
|
614
|
+
return RememberResponse.model_validate(data)
|
|
615
|
+
|
|
616
|
+
async def recall(
|
|
617
|
+
self,
|
|
618
|
+
query: str,
|
|
619
|
+
tenant_id: str = "default",
|
|
620
|
+
) -> RecallResponse:
|
|
621
|
+
"""Retrieve relevant memories matching a query.
|
|
622
|
+
|
|
623
|
+
Args:
|
|
624
|
+
query: Natural-language query.
|
|
625
|
+
tenant_id: Tenant namespace.
|
|
626
|
+
|
|
627
|
+
Returns:
|
|
628
|
+
A ``RecallResponse`` with context and confidence.
|
|
629
|
+
"""
|
|
630
|
+
payload: dict[str, Any] = {
|
|
631
|
+
"query": query,
|
|
632
|
+
"tenant_id": tenant_id,
|
|
633
|
+
}
|
|
634
|
+
data = await self._request("POST", "/v1/recall", json=payload)
|
|
635
|
+
return RecallResponse.model_validate(data)
|
|
636
|
+
|
|
637
|
+
async def forget(
|
|
638
|
+
self,
|
|
639
|
+
query: str | None = None,
|
|
640
|
+
reason: str = "",
|
|
641
|
+
tenant_id: str = "default",
|
|
642
|
+
) -> ForgetResponse:
|
|
643
|
+
"""Delete memories matching a query.
|
|
644
|
+
|
|
645
|
+
Args:
|
|
646
|
+
query: Pattern or identifier of memories to delete.
|
|
647
|
+
reason: Human-readable justification for the deletion.
|
|
648
|
+
tenant_id: Tenant namespace.
|
|
649
|
+
|
|
650
|
+
Returns:
|
|
651
|
+
A ``ForgetResponse`` indicating how many records were forgotten.
|
|
652
|
+
"""
|
|
653
|
+
payload: dict[str, Any] = {"reason": reason, "tenant_id": tenant_id}
|
|
654
|
+
if query is not None:
|
|
655
|
+
payload["query"] = query
|
|
656
|
+
data = await self._request("POST", "/v1/forget", json=payload)
|
|
657
|
+
return ForgetResponse.model_validate(data)
|
|
658
|
+
|
|
659
|
+
async def ingest_episode(self, episode: IngestEpisodeRequest) -> IngestEpisodeResponse:
|
|
660
|
+
"""Ingest a structured episode into CortexDB.
|
|
661
|
+
|
|
662
|
+
Args:
|
|
663
|
+
episode: The episode to ingest.
|
|
664
|
+
|
|
665
|
+
Returns:
|
|
666
|
+
An ``IngestEpisodeResponse`` with the episode ID and status.
|
|
667
|
+
"""
|
|
668
|
+
payload = episode.model_dump(
|
|
669
|
+
by_alias=False, exclude_none=True, exclude={"tenant_id"}
|
|
670
|
+
)
|
|
671
|
+
data = await self._request("POST", "/v1/episodes", json=payload)
|
|
672
|
+
return IngestEpisodeResponse.model_validate(data)
|
|
673
|
+
|
|
674
|
+
async def list_episodes(
|
|
675
|
+
self,
|
|
676
|
+
tenant_id: str = "default",
|
|
677
|
+
offset: int = 0,
|
|
678
|
+
limit: int = 50,
|
|
679
|
+
**kwargs: Any,
|
|
680
|
+
) -> ListEpisodesResponse:
|
|
681
|
+
"""List episodes with optional filtering.
|
|
682
|
+
|
|
683
|
+
Args:
|
|
684
|
+
tenant_id: Tenant namespace.
|
|
685
|
+
offset: Pagination offset.
|
|
686
|
+
limit: Maximum number of episodes to return.
|
|
687
|
+
**kwargs: Additional query parameters forwarded to the API.
|
|
688
|
+
|
|
689
|
+
Returns:
|
|
690
|
+
A ``ListEpisodesResponse`` with the episode list and pagination info.
|
|
691
|
+
"""
|
|
692
|
+
params: dict[str, Any] = {
|
|
693
|
+
"tenant_id": tenant_id,
|
|
694
|
+
"offset": offset,
|
|
695
|
+
"limit": limit,
|
|
696
|
+
**kwargs,
|
|
697
|
+
}
|
|
698
|
+
data = await self._request("GET", "/v1/episodes", params=params)
|
|
699
|
+
return ListEpisodesResponse.model_validate(data)
|
|
700
|
+
|
|
701
|
+
async def health(self) -> HealthResponse:
|
|
702
|
+
"""Check server health.
|
|
703
|
+
|
|
704
|
+
Returns:
|
|
705
|
+
A ``HealthResponse`` with the server's status and version.
|
|
706
|
+
"""
|
|
707
|
+
data = await self._request("GET", "/v1/admin/health")
|
|
708
|
+
return HealthResponse.model_validate(data)
|
|
709
|
+
|
|
710
|
+
async def metrics(self) -> MetricsResponse:
|
|
711
|
+
"""Retrieve server metrics.
|
|
712
|
+
|
|
713
|
+
Returns:
|
|
714
|
+
A ``MetricsResponse`` with episode counts and storage usage.
|
|
715
|
+
"""
|
|
716
|
+
data = await self._request("GET", "/v1/admin/metrics")
|
|
717
|
+
return MetricsResponse.model_validate(data)
|
|
718
|
+
|
|
719
|
+
async def entity(self, entity_id: str, tenant_id: str = "default") -> EntityResponse:
|
|
720
|
+
"""Get detailed information about a specific entity.
|
|
721
|
+
|
|
722
|
+
Returns entity metadata, relationships, and recent episodes.
|
|
723
|
+
|
|
724
|
+
Args:
|
|
725
|
+
entity_id: The entity ID to look up.
|
|
726
|
+
tenant_id: Tenant namespace.
|
|
727
|
+
|
|
728
|
+
Returns:
|
|
729
|
+
An ``EntityResponse`` with entity details, relationships, and episodes.
|
|
730
|
+
"""
|
|
731
|
+
data = await self._request(
|
|
732
|
+
"GET",
|
|
733
|
+
f"/v1/entities/{entity_id}",
|
|
734
|
+
headers={"x-tenant-id": tenant_id},
|
|
735
|
+
)
|
|
736
|
+
return EntityResponse.model_validate(data)
|
|
737
|
+
|
|
738
|
+
async def link(
|
|
739
|
+
self,
|
|
740
|
+
source_entity_id: str,
|
|
741
|
+
target_entity_id: str,
|
|
742
|
+
relationship: str,
|
|
743
|
+
fact: str = "",
|
|
744
|
+
confidence: float = 0.9,
|
|
745
|
+
tenant_id: str = "default",
|
|
746
|
+
) -> LinkResponse:
|
|
747
|
+
"""Create an explicit relationship between two entities.
|
|
748
|
+
|
|
749
|
+
Args:
|
|
750
|
+
source_entity_id: ID of the source entity.
|
|
751
|
+
target_entity_id: ID of the target entity.
|
|
752
|
+
relationship: Relationship type (e.g., ``"DEPENDS_ON"``, ``"OWNS"``).
|
|
753
|
+
fact: Human-readable description of the relationship.
|
|
754
|
+
confidence: Confidence score (0.0 to 1.0).
|
|
755
|
+
tenant_id: Tenant namespace.
|
|
756
|
+
|
|
757
|
+
Returns:
|
|
758
|
+
A ``LinkResponse`` with the created edge ID.
|
|
759
|
+
"""
|
|
760
|
+
payload = {
|
|
761
|
+
"source_entity_id": source_entity_id,
|
|
762
|
+
"target_entity_id": target_entity_id,
|
|
763
|
+
"relationship": relationship,
|
|
764
|
+
"fact": fact,
|
|
765
|
+
"confidence": confidence,
|
|
766
|
+
}
|
|
767
|
+
data = await self._request(
|
|
768
|
+
"POST",
|
|
769
|
+
"/v1/link",
|
|
770
|
+
json=payload,
|
|
771
|
+
headers={"x-tenant-id": tenant_id},
|
|
772
|
+
)
|
|
773
|
+
return LinkResponse.model_validate(data)
|
|
774
|
+
|
|
775
|
+
async def search(
|
|
776
|
+
self,
|
|
777
|
+
query: str,
|
|
778
|
+
source: str | None = None,
|
|
779
|
+
episode_type: str | None = None,
|
|
780
|
+
time_range: str | None = None,
|
|
781
|
+
namespace: str | None = None,
|
|
782
|
+
limit: int = 20,
|
|
783
|
+
offset: int = 0,
|
|
784
|
+
tenant_id: str = "default",
|
|
785
|
+
) -> SearchResponse:
|
|
786
|
+
"""Search across memories and episodes with filters.
|
|
787
|
+
|
|
788
|
+
Unlike :meth:`recall`, this returns both context and matching episodes.
|
|
789
|
+
|
|
790
|
+
Args:
|
|
791
|
+
query: Natural-language search query.
|
|
792
|
+
source: Filter by source connector (e.g., ``"slack"``, ``"github"``).
|
|
793
|
+
episode_type: Filter by episode type (e.g., ``"message"``, ``"incident"``).
|
|
794
|
+
time_range: Time range filter (e.g., ``"1h"``, ``"24h"``, ``"7d"``, ``"30d"``).
|
|
795
|
+
namespace: Filter by namespace.
|
|
796
|
+
limit: Maximum results to return.
|
|
797
|
+
offset: Pagination offset.
|
|
798
|
+
tenant_id: Tenant namespace.
|
|
799
|
+
|
|
800
|
+
Returns:
|
|
801
|
+
A ``SearchResponse`` with context, episodes, and metadata.
|
|
802
|
+
"""
|
|
803
|
+
payload: dict[str, Any] = {"query": query, "limit": limit, "offset": offset}
|
|
804
|
+
filters: dict[str, str] = {}
|
|
805
|
+
if source:
|
|
806
|
+
filters["source"] = source
|
|
807
|
+
if episode_type:
|
|
808
|
+
filters["episode_type"] = episode_type
|
|
809
|
+
if time_range:
|
|
810
|
+
filters["time_range"] = time_range
|
|
811
|
+
if namespace:
|
|
812
|
+
filters["namespace"] = namespace
|
|
813
|
+
if filters:
|
|
814
|
+
payload["filters"] = filters
|
|
815
|
+
data = await self._request(
|
|
816
|
+
"POST",
|
|
817
|
+
"/v1/search",
|
|
818
|
+
json=payload,
|
|
819
|
+
headers={"x-tenant-id": tenant_id},
|
|
820
|
+
)
|
|
821
|
+
return SearchResponse.model_validate(data)
|
|
822
|
+
|
|
823
|
+
# -- GTM convenience methods ---------------------------------------------
|
|
824
|
+
|
|
825
|
+
async def memory(
|
|
826
|
+
self,
|
|
827
|
+
query: str,
|
|
828
|
+
tenant_id: str = "default",
|
|
829
|
+
**kwargs: Any,
|
|
830
|
+
) -> RecallResponse:
|
|
831
|
+
"""Recall memories with a single natural-language query.
|
|
832
|
+
|
|
833
|
+
This is the GTM one-liner::
|
|
834
|
+
|
|
835
|
+
await cortex.memory("why did checkout fail yesterday")
|
|
836
|
+
|
|
837
|
+
It is an alias for :meth:`recall` with the same parameter semantics.
|
|
838
|
+
|
|
839
|
+
Args:
|
|
840
|
+
query: Natural-language query.
|
|
841
|
+
tenant_id: Tenant namespace.
|
|
842
|
+
**kwargs: Additional keyword arguments forwarded to :meth:`recall`
|
|
843
|
+
(e.g. ``max_tokens``, ``min_confidence``).
|
|
844
|
+
|
|
845
|
+
Returns:
|
|
846
|
+
A ``RecallResponse`` containing ranked matches.
|
|
847
|
+
"""
|
|
848
|
+
return await self.recall(query=query, tenant_id=tenant_id, **kwargs)
|
|
849
|
+
|
|
850
|
+
async def store(
|
|
851
|
+
self,
|
|
852
|
+
data: str | dict[str, Any],
|
|
853
|
+
tenant_id: str = "default",
|
|
854
|
+
**kwargs: Any,
|
|
855
|
+
) -> RememberResponse | IngestEpisodeResponse:
|
|
856
|
+
"""Smart store that accepts a string or a dict.
|
|
857
|
+
|
|
858
|
+
Usage::
|
|
859
|
+
|
|
860
|
+
await cortex.store("The deploy succeeded at 14:32 UTC.")
|
|
861
|
+
await cortex.store({"content": "deploy ok", "type": "event", "source": "ci"})
|
|
862
|
+
|
|
863
|
+
When *data* is a **string**, it delegates to :meth:`remember`.
|
|
864
|
+
When *data* is a **dict**, it converts it via
|
|
865
|
+
:meth:`IngestEpisodeRequest.from_dict` and delegates to
|
|
866
|
+
:meth:`ingest_episode`.
|
|
867
|
+
|
|
868
|
+
Args:
|
|
869
|
+
data: Either a plain string (stored via ``remember``) or a dict
|
|
870
|
+
describing a structured episode (stored via ``ingest_episode``).
|
|
871
|
+
tenant_id: Tenant namespace (used for both code paths).
|
|
872
|
+
**kwargs: Extra keyword arguments forwarded to ``remember`` when
|
|
873
|
+
*data* is a string.
|
|
874
|
+
|
|
875
|
+
Returns:
|
|
876
|
+
A ``RememberResponse`` (string path) or
|
|
877
|
+
``IngestEpisodeResponse`` (dict path).
|
|
878
|
+
|
|
879
|
+
Raises:
|
|
880
|
+
TypeError: If *data* is neither a string nor a dict.
|
|
881
|
+
"""
|
|
882
|
+
if isinstance(data, str):
|
|
883
|
+
return await self.remember(content=data, tenant_id=tenant_id, **kwargs)
|
|
884
|
+
|
|
885
|
+
if isinstance(data, dict):
|
|
886
|
+
data.setdefault("tenant_id", tenant_id)
|
|
887
|
+
episode = IngestEpisodeRequest.from_dict(data)
|
|
888
|
+
return await self.ingest_episode(episode)
|
|
889
|
+
|
|
890
|
+
raise TypeError(
|
|
891
|
+
f"store() expects a str or dict, got {type(data).__name__}"
|
|
892
|
+
)
|
|
893
|
+
|
|
894
|
+
async def context(
|
|
895
|
+
self,
|
|
896
|
+
query: str,
|
|
897
|
+
agent_id: str = "default",
|
|
898
|
+
tenant_id: str = "default",
|
|
899
|
+
max_tokens: int = 8192,
|
|
900
|
+
) -> RecallResponse:
|
|
901
|
+
"""Retrieve enriched context for an LLM agent.
|
|
902
|
+
|
|
903
|
+
Designed for feeding context windows to autonomous agents. Uses a
|
|
904
|
+
higher default token budget than :meth:`recall`.
|
|
905
|
+
|
|
906
|
+
Usage::
|
|
907
|
+
|
|
908
|
+
await cortex.context(agent_id="incident-bot", query="current status of payments")
|
|
909
|
+
|
|
910
|
+
Args:
|
|
911
|
+
query: Natural-language query describing what context is needed.
|
|
912
|
+
agent_id: Identifier of the requesting agent (reserved for
|
|
913
|
+
future per-agent retrieval policies).
|
|
914
|
+
tenant_id: Tenant namespace.
|
|
915
|
+
max_tokens: Maximum token budget for the response (default 8192).
|
|
916
|
+
|
|
917
|
+
Returns:
|
|
918
|
+
A ``RecallResponse`` containing ranked matches.
|
|
919
|
+
"""
|
|
920
|
+
return await self.recall(
|
|
921
|
+
query=query,
|
|
922
|
+
tenant_id=tenant_id,
|
|
923
|
+
max_tokens=max_tokens,
|
|
924
|
+
)
|
|
925
|
+
|
|
926
|
+
# -- internal ------------------------------------------------------------
|
|
927
|
+
|
|
928
|
+
async def _request(self, method: str, path: str, **kwargs: Any) -> Any:
|
|
929
|
+
"""Execute an async HTTP request with retry logic.
|
|
930
|
+
|
|
931
|
+
Returns:
|
|
932
|
+
Decoded JSON response body.
|
|
933
|
+
|
|
934
|
+
Raises:
|
|
935
|
+
CortexAPIError: On non-retryable HTTP errors.
|
|
936
|
+
CortexConnectionError: When the server is unreachable.
|
|
937
|
+
CortexTimeoutError: When the request exceeds the configured timeout.
|
|
938
|
+
"""
|
|
939
|
+
|
|
940
|
+
@retry(
|
|
941
|
+
retry=retry_if_exception(_is_retryable),
|
|
942
|
+
stop=stop_after_attempt(self._max_retries),
|
|
943
|
+
wait=wait_exponential(multiplier=0.5, min=0.5, max=10),
|
|
944
|
+
reraise=True,
|
|
945
|
+
)
|
|
946
|
+
async def _do() -> Any:
|
|
947
|
+
try:
|
|
948
|
+
response = await self._client.request(method, path, **kwargs)
|
|
949
|
+
except httpx.TimeoutException as exc:
|
|
950
|
+
raise CortexTimeoutError(str(exc)) from exc
|
|
951
|
+
except httpx.ConnectError as exc:
|
|
952
|
+
raise CortexConnectionError(str(exc)) from exc
|
|
953
|
+
except httpx.HTTPError as exc:
|
|
954
|
+
raise CortexConnectionError(str(exc)) from exc
|
|
955
|
+
|
|
956
|
+
_raise_for_status(response)
|
|
957
|
+
|
|
958
|
+
if response.status_code == 204:
|
|
959
|
+
return {}
|
|
960
|
+
return response.json()
|
|
961
|
+
|
|
962
|
+
return await _do()
|