cortexlayer 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.
- cortexlayer/__init__.py +61 -0
- cortexlayer/_engine/__init__.py +0 -0
- cortexlayer/_engine/compression.py +87 -0
- cortexlayer/_engine/facts/__init__.py +1 -0
- cortexlayer/_engine/facts/additive_extraction_prompt.txt +476 -0
- cortexlayer/_engine/facts/backends.py +196 -0
- cortexlayer/_engine/facts/engine.py +556 -0
- cortexlayer/_engine/facts/entities.py +796 -0
- cortexlayer/_engine/facts/prompts.py +100 -0
- cortexlayer/_engine/facts/scoring.py +148 -0
- cortexlayer/_engine/ingestion.py +84 -0
- cortexlayer/_engine/linking.py +68 -0
- cortexlayer/_engine/nlp.py +162 -0
- cortexlayer/_engine/retrieval.py +55 -0
- cortexlayer/_engine/shaping.py +96 -0
- cortexlayer/_engine/storage.py +295 -0
- cortexlayer/_validate.py +23 -0
- cortexlayer/_version.py +1 -0
- cortexlayer/client.py +526 -0
- cortexlayer/errors.py +108 -0
- cortexlayer/memory.py +372 -0
- cortexlayer/py.typed +0 -0
- cortexlayer/types.py +199 -0
- cortexlayer-0.1.0.dist-info/METADATA +266 -0
- cortexlayer-0.1.0.dist-info/RECORD +29 -0
- cortexlayer-0.1.0.dist-info/WHEEL +5 -0
- cortexlayer-0.1.0.dist-info/licenses/LICENSE +202 -0
- cortexlayer-0.1.0.dist-info/licenses/NOTICE +40 -0
- cortexlayer-0.1.0.dist-info/top_level.txt +1 -0
cortexlayer/client.py
ADDED
|
@@ -0,0 +1,526 @@
|
|
|
1
|
+
"""Sync and async clients for the Cortex HTTP API (``/v1/*``).
|
|
2
|
+
|
|
3
|
+
Both classes share every request builder and response parser (``_Base``); they
|
|
4
|
+
differ only in the transport call and the retry sleep, so the two surfaces
|
|
5
|
+
cannot drift apart.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import datetime as _dt
|
|
12
|
+
import os
|
|
13
|
+
import time
|
|
14
|
+
from typing import Any, Callable, Dict, List, Optional, Tuple, Union
|
|
15
|
+
from urllib.parse import quote
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
|
|
19
|
+
from ._validate import need_int, need_str
|
|
20
|
+
from ._version import __version__
|
|
21
|
+
from .errors import (
|
|
22
|
+
AuthenticationError,
|
|
23
|
+
ConflictError,
|
|
24
|
+
ConnectionError,
|
|
25
|
+
CortexConfigError,
|
|
26
|
+
CortexError,
|
|
27
|
+
InvalidRequestError,
|
|
28
|
+
NotFoundError,
|
|
29
|
+
PermissionDeniedError,
|
|
30
|
+
RateLimitError,
|
|
31
|
+
ServerError,
|
|
32
|
+
WritesNotSupportedError,
|
|
33
|
+
)
|
|
34
|
+
from .types import (
|
|
35
|
+
Account,
|
|
36
|
+
AddResult,
|
|
37
|
+
Graph,
|
|
38
|
+
Page,
|
|
39
|
+
PageList,
|
|
40
|
+
SearchResult,
|
|
41
|
+
Usage,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
DEFAULT_BASE_URL = "https://api.cortexlayer.net"
|
|
45
|
+
API_KEY_ENV = "CORTEX_API_KEY"
|
|
46
|
+
BASE_URL_ENV = "CORTEX_BASE_URL"
|
|
47
|
+
|
|
48
|
+
MAX_SEARCH_LIMIT = 20 # server: top_k 1..20
|
|
49
|
+
MAX_LIST_LIMIT = 500 # server: /v1/pages limit 1..500
|
|
50
|
+
USAGE_GROUPS = ("day", "key", "operation")
|
|
51
|
+
_RETRY_STATUS = (502, 503, 504)
|
|
52
|
+
|
|
53
|
+
DateLike = Union[str, _dt.date, _dt.datetime]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class _Op:
|
|
57
|
+
"""One prepared request: what to send and how to read the answer."""
|
|
58
|
+
|
|
59
|
+
__slots__ = ("method", "path", "params", "json", "parse", "retryable", "write")
|
|
60
|
+
|
|
61
|
+
def __init__(
|
|
62
|
+
self,
|
|
63
|
+
method: str,
|
|
64
|
+
path: str,
|
|
65
|
+
parse: Callable[[Any], Any],
|
|
66
|
+
*,
|
|
67
|
+
params: Optional[Dict[str, Any]] = None,
|
|
68
|
+
json: Optional[Dict[str, Any]] = None,
|
|
69
|
+
retryable: bool = False,
|
|
70
|
+
write: bool = False,
|
|
71
|
+
) -> None:
|
|
72
|
+
self.method = method
|
|
73
|
+
self.path = path
|
|
74
|
+
self.parse = parse
|
|
75
|
+
self.params = params
|
|
76
|
+
self.json = json
|
|
77
|
+
# Only safe, side-effect-free calls are retried on transport errors and
|
|
78
|
+
# 502/503/504: a retried write could apply twice.
|
|
79
|
+
self.retryable = retryable
|
|
80
|
+
self.write = write
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
_need_str = need_str
|
|
84
|
+
_need_int = need_int
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _iso(value: DateLike, name: str) -> str:
|
|
88
|
+
if isinstance(value, (_dt.datetime, _dt.date)):
|
|
89
|
+
return value.isoformat()
|
|
90
|
+
if isinstance(value, str) and value.strip():
|
|
91
|
+
return value.strip()
|
|
92
|
+
raise InvalidRequestError(f"{name} must be an ISO date/datetime string or a date")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _page_path(page_id: str, suffix: str = "") -> str:
|
|
96
|
+
_need_str(page_id, "id")
|
|
97
|
+
return f"/v1/pages/{quote(page_id, safe='')}{suffix}"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _ok_none(_: Any) -> None:
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class _Base:
|
|
105
|
+
"""Config + request building + response handling, transport-free."""
|
|
106
|
+
|
|
107
|
+
def __init__(
|
|
108
|
+
self,
|
|
109
|
+
api_key: Optional[str] = None,
|
|
110
|
+
base_url: Optional[str] = None,
|
|
111
|
+
*,
|
|
112
|
+
timeout: float = 30.0,
|
|
113
|
+
max_retries: int = 2,
|
|
114
|
+
retry_backoff: float = 0.5,
|
|
115
|
+
) -> None:
|
|
116
|
+
key = api_key if api_key is not None else os.environ.get(API_KEY_ENV)
|
|
117
|
+
url = base_url or os.environ.get(BASE_URL_ENV) or DEFAULT_BASE_URL
|
|
118
|
+
if key is not None and (not isinstance(key, str) or not key.strip()):
|
|
119
|
+
raise CortexConfigError("api_key must be a non-empty string")
|
|
120
|
+
if not isinstance(url, str) or not url.lower().startswith(("http://", "https://")):
|
|
121
|
+
raise CortexConfigError("base_url must start with http:// or https://")
|
|
122
|
+
if key is None and url.rstrip("/") == DEFAULT_BASE_URL:
|
|
123
|
+
raise CortexConfigError(
|
|
124
|
+
f"No API key. Pass api_key=... or set {API_KEY_ENV} "
|
|
125
|
+
"(create one in the Cortex web app under Keys)."
|
|
126
|
+
)
|
|
127
|
+
if max_retries < 0:
|
|
128
|
+
raise CortexConfigError("max_retries must be >= 0")
|
|
129
|
+
self._api_key = key.strip() if key else None
|
|
130
|
+
self._base_url = url.rstrip("/")
|
|
131
|
+
self._timeout = timeout
|
|
132
|
+
self._max_retries = max_retries
|
|
133
|
+
self._backoff = max(0.0, retry_backoff)
|
|
134
|
+
|
|
135
|
+
def __repr__(self) -> str:
|
|
136
|
+
key = "None" if not self._api_key else f"'…{self._api_key[-4:]}'"
|
|
137
|
+
return f"{type(self).__name__}(base_url={self._base_url!r}, api_key={key})"
|
|
138
|
+
|
|
139
|
+
# --- request plumbing ---
|
|
140
|
+
|
|
141
|
+
def _headers(self) -> Dict[str, str]:
|
|
142
|
+
h = {
|
|
143
|
+
"Accept": "application/json",
|
|
144
|
+
"User-Agent": f"cortexlayer-python/{__version__}",
|
|
145
|
+
}
|
|
146
|
+
if self._api_key:
|
|
147
|
+
h["Authorization"] = f"Bearer {self._api_key}"
|
|
148
|
+
return h
|
|
149
|
+
|
|
150
|
+
def _url(self, op: _Op) -> str:
|
|
151
|
+
return self._base_url + op.path
|
|
152
|
+
|
|
153
|
+
def _delay(self, attempt: int) -> float:
|
|
154
|
+
return min(self._backoff * (2 ** attempt), 4.0)
|
|
155
|
+
|
|
156
|
+
def _finish(self, op: _Op, resp: httpx.Response) -> Any:
|
|
157
|
+
if resp.status_code >= 400:
|
|
158
|
+
raise self._error_for(op, resp)
|
|
159
|
+
try:
|
|
160
|
+
data = resp.json()
|
|
161
|
+
except ValueError as e:
|
|
162
|
+
raise ServerError(
|
|
163
|
+
"Unexpected non-JSON response from server", status=resp.status_code
|
|
164
|
+
) from e
|
|
165
|
+
try:
|
|
166
|
+
return op.parse(data)
|
|
167
|
+
except (KeyError, TypeError, ValueError, AttributeError) as e:
|
|
168
|
+
raise ServerError(
|
|
169
|
+
f"Unexpected response shape from {op.method} {op.path}",
|
|
170
|
+
status=resp.status_code,
|
|
171
|
+
) from e
|
|
172
|
+
|
|
173
|
+
@staticmethod
|
|
174
|
+
def _error_message(resp: httpx.Response) -> Tuple[str, bool]:
|
|
175
|
+
"""(message, came_from_a_json_error_body)."""
|
|
176
|
+
try:
|
|
177
|
+
body = resp.json()
|
|
178
|
+
except ValueError:
|
|
179
|
+
return (resp.text.strip()[:200] or resp.reason_phrase or "error"), False
|
|
180
|
+
if isinstance(body, dict) and isinstance(body.get("error"), str):
|
|
181
|
+
return body["error"], True
|
|
182
|
+
return (str(body)[:200], False)
|
|
183
|
+
|
|
184
|
+
def _error_for(self, op: _Op, resp: httpx.Response) -> CortexError:
|
|
185
|
+
status = resp.status_code
|
|
186
|
+
msg, from_json = self._error_message(resp)
|
|
187
|
+
if op.write and (status == 405 or (status == 404 and not from_json)):
|
|
188
|
+
return WritesNotSupportedError(
|
|
189
|
+
"This Cortex server doesn't support REST writes "
|
|
190
|
+
f"({op.method} {op.path} → {status}). It is running a version "
|
|
191
|
+
"older than server task 0073 — update/redeploy the server.",
|
|
192
|
+
status=status,
|
|
193
|
+
)
|
|
194
|
+
if status in (400, 422):
|
|
195
|
+
return InvalidRequestError(msg, status=status)
|
|
196
|
+
if status == 401:
|
|
197
|
+
return AuthenticationError(
|
|
198
|
+
f"{msg} — check your API key (it may be missing, wrong or revoked).",
|
|
199
|
+
status=401,
|
|
200
|
+
)
|
|
201
|
+
if status == 403:
|
|
202
|
+
return PermissionDeniedError(msg, status=403)
|
|
203
|
+
if status == 404:
|
|
204
|
+
return NotFoundError(msg, status=404)
|
|
205
|
+
if status == 409:
|
|
206
|
+
return ConflictError(msg, status=409)
|
|
207
|
+
if status == 429:
|
|
208
|
+
ra = resp.headers.get("Retry-After")
|
|
209
|
+
try:
|
|
210
|
+
retry_after: Optional[float] = float(ra) if ra is not None else None
|
|
211
|
+
except ValueError:
|
|
212
|
+
retry_after = None
|
|
213
|
+
return RateLimitError(msg, status=429, retry_after=retry_after)
|
|
214
|
+
if status >= 500:
|
|
215
|
+
return ServerError(msg, status=status)
|
|
216
|
+
return CortexError(msg, status=status)
|
|
217
|
+
|
|
218
|
+
# --- operations (transport-free; shared by sync + async) ---
|
|
219
|
+
|
|
220
|
+
@staticmethod
|
|
221
|
+
def _op_add(text: str, timestamp: Optional[str]) -> _Op:
|
|
222
|
+
body: Dict[str, Any] = {"text": _need_str(text, "text")}
|
|
223
|
+
if timestamp is not None:
|
|
224
|
+
body["timestamp"] = _need_str(timestamp, "timestamp")
|
|
225
|
+
return _Op("POST", "/v1/pages", AddResult.from_dict, json=body, write=True)
|
|
226
|
+
|
|
227
|
+
@staticmethod
|
|
228
|
+
def _op_search(query: str, limit: int, expand_links: bool) -> _Op:
|
|
229
|
+
_need_str(query, "query")
|
|
230
|
+
_need_int(limit, "limit", 1, MAX_SEARCH_LIMIT)
|
|
231
|
+
if not isinstance(expand_links, bool):
|
|
232
|
+
raise InvalidRequestError("expand_links must be True or False")
|
|
233
|
+
return _Op(
|
|
234
|
+
"POST", "/v1/search",
|
|
235
|
+
lambda d: [SearchResult.from_dict(r) for r in d["results"]],
|
|
236
|
+
json={"query": query, "top_k": limit, "expand_links": expand_links},
|
|
237
|
+
retryable=True,
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
@staticmethod
|
|
241
|
+
def _op_get(page_id: str) -> _Op:
|
|
242
|
+
return _Op("GET", _page_path(page_id), Page.from_dict, retryable=True)
|
|
243
|
+
|
|
244
|
+
@staticmethod
|
|
245
|
+
def _op_get_all(query: Optional[str], limit: int, offset: int) -> _Op:
|
|
246
|
+
_need_int(limit, "limit", 1, MAX_LIST_LIMIT)
|
|
247
|
+
_need_int(offset, "offset", 0)
|
|
248
|
+
params: Dict[str, Any] = {"limit": limit, "offset": offset}
|
|
249
|
+
if query is not None:
|
|
250
|
+
params["q"] = _need_str(query, "query")
|
|
251
|
+
return _Op("GET", "/v1/pages", PageList.from_dict, params=params, retryable=True)
|
|
252
|
+
|
|
253
|
+
@staticmethod
|
|
254
|
+
def _op_update(page_id: str, text: str) -> _Op:
|
|
255
|
+
return _Op(
|
|
256
|
+
"PATCH", _page_path(page_id), _ok_none,
|
|
257
|
+
json={"text": _need_str(text, "text")}, write=True,
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
@staticmethod
|
|
261
|
+
def _op_delete(page_id: str) -> _Op:
|
|
262
|
+
return _Op("DELETE", _page_path(page_id), _ok_none, write=True)
|
|
263
|
+
|
|
264
|
+
@staticmethod
|
|
265
|
+
def _op_relink() -> _Op:
|
|
266
|
+
return _Op("POST", "/v1/relink", dict, write=True)
|
|
267
|
+
|
|
268
|
+
@staticmethod
|
|
269
|
+
def _op_me() -> _Op:
|
|
270
|
+
return _Op("GET", "/v1/me", Account.from_dict, retryable=True)
|
|
271
|
+
|
|
272
|
+
@staticmethod
|
|
273
|
+
def _op_usage(
|
|
274
|
+
group_by: str, since: Optional[DateLike], until: Optional[DateLike]
|
|
275
|
+
) -> _Op:
|
|
276
|
+
if group_by not in USAGE_GROUPS:
|
|
277
|
+
raise InvalidRequestError(f"group_by must be one of {', '.join(USAGE_GROUPS)}")
|
|
278
|
+
params: Dict[str, Any] = {"group_by": group_by}
|
|
279
|
+
if since is not None:
|
|
280
|
+
params["since"] = _iso(since, "since")
|
|
281
|
+
if until is not None:
|
|
282
|
+
params["until"] = _iso(until, "until")
|
|
283
|
+
return _Op("GET", "/v1/usage", Usage.from_dict, params=params, retryable=True)
|
|
284
|
+
|
|
285
|
+
@staticmethod
|
|
286
|
+
def _op_graph(limit: Optional[int]) -> _Op:
|
|
287
|
+
params = {} if limit is None else {"limit": _need_int(limit, "limit", 1, 5000)}
|
|
288
|
+
return _Op("GET", "/v1/graph", Graph.from_dict, params=params, retryable=True)
|
|
289
|
+
|
|
290
|
+
@staticmethod
|
|
291
|
+
def _op_neighbors(page_id: str, depth: int, limit: Optional[int]) -> _Op:
|
|
292
|
+
_need_int(depth, "depth", 1, 3)
|
|
293
|
+
params: Dict[str, Any] = {"depth": depth}
|
|
294
|
+
if limit is not None:
|
|
295
|
+
params["limit"] = _need_int(limit, "limit", 1)
|
|
296
|
+
return _Op(
|
|
297
|
+
"GET", _page_path(page_id, "/neighbors"), Graph.from_dict,
|
|
298
|
+
params=params, retryable=True,
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
class CortexClient(_Base):
|
|
303
|
+
"""Synchronous client.
|
|
304
|
+
|
|
305
|
+
>>> c = CortexClient(api_key="ctx_...") # doctest: +SKIP
|
|
306
|
+
>>> c.add("I moved to Lisbon in March") # doctest: +SKIP
|
|
307
|
+
>>> [r.title for r in c.search("where do I live?")] # doctest: +SKIP
|
|
308
|
+
|
|
309
|
+
Use as a context manager (or call :meth:`close`) to release connections.
|
|
310
|
+
Pass ``http_client`` to supply your own ``httpx.Client`` (proxies, custom
|
|
311
|
+
transports, tests); it is never closed for you.
|
|
312
|
+
"""
|
|
313
|
+
|
|
314
|
+
def __init__(
|
|
315
|
+
self,
|
|
316
|
+
api_key: Optional[str] = None,
|
|
317
|
+
base_url: Optional[str] = None,
|
|
318
|
+
*,
|
|
319
|
+
timeout: float = 30.0,
|
|
320
|
+
max_retries: int = 2,
|
|
321
|
+
retry_backoff: float = 0.5,
|
|
322
|
+
http_client: Optional[httpx.Client] = None,
|
|
323
|
+
) -> None:
|
|
324
|
+
super().__init__(
|
|
325
|
+
api_key, base_url, timeout=timeout,
|
|
326
|
+
max_retries=max_retries, retry_backoff=retry_backoff,
|
|
327
|
+
)
|
|
328
|
+
self._owns_http = http_client is None
|
|
329
|
+
self._http = http_client or httpx.Client(timeout=timeout)
|
|
330
|
+
|
|
331
|
+
def close(self) -> None:
|
|
332
|
+
if self._owns_http:
|
|
333
|
+
self._http.close()
|
|
334
|
+
|
|
335
|
+
def __enter__(self) -> "CortexClient":
|
|
336
|
+
return self
|
|
337
|
+
|
|
338
|
+
def __exit__(self, *exc: Any) -> None:
|
|
339
|
+
self.close()
|
|
340
|
+
|
|
341
|
+
def _call(self, op: _Op) -> Any:
|
|
342
|
+
attempt = 0
|
|
343
|
+
while True:
|
|
344
|
+
try:
|
|
345
|
+
resp = self._http.request(
|
|
346
|
+
op.method, self._url(op), headers=self._headers(),
|
|
347
|
+
params=op.params, json=op.json, timeout=self._timeout,
|
|
348
|
+
)
|
|
349
|
+
except httpx.HTTPError as e:
|
|
350
|
+
if op.retryable and attempt < self._max_retries:
|
|
351
|
+
time.sleep(self._delay(attempt))
|
|
352
|
+
attempt += 1
|
|
353
|
+
continue
|
|
354
|
+
raise ConnectionError(
|
|
355
|
+
f"Could not reach {self._base_url}: {e.__class__.__name__}: {e}"
|
|
356
|
+
) from e
|
|
357
|
+
if resp.status_code in _RETRY_STATUS and op.retryable and attempt < self._max_retries:
|
|
358
|
+
time.sleep(self._delay(attempt))
|
|
359
|
+
attempt += 1
|
|
360
|
+
continue
|
|
361
|
+
return self._finish(op, resp)
|
|
362
|
+
|
|
363
|
+
# --- memory ---
|
|
364
|
+
|
|
365
|
+
def add(self, text: str, *, timestamp: Optional[str] = None) -> AddResult:
|
|
366
|
+
"""Save ``text`` as memory (long text is chunked into several pages).
|
|
367
|
+
``timestamp`` (e.g. ``"8 May, 2023"``) is stored as the date.
|
|
368
|
+
|
|
369
|
+
Needs a server with REST writes (Cortex server task 0073); raises
|
|
370
|
+
:class:`WritesNotSupportedError` on older servers."""
|
|
371
|
+
return self._call(self._op_add(text, timestamp))
|
|
372
|
+
|
|
373
|
+
def search(
|
|
374
|
+
self, query: str, *, limit: int = 4, expand_links: bool = True
|
|
375
|
+
) -> List[SearchResult]:
|
|
376
|
+
"""Semantic search. With ``expand_links`` (default) related pages are
|
|
377
|
+
pulled in via links — those results have ``via == "link"``."""
|
|
378
|
+
return self._call(self._op_search(query, limit, expand_links))
|
|
379
|
+
|
|
380
|
+
def get(self, id: str) -> Page:
|
|
381
|
+
"""One page by id. Unknown ids raise :class:`NotFoundError`."""
|
|
382
|
+
return self._call(self._op_get(id))
|
|
383
|
+
|
|
384
|
+
def get_all(
|
|
385
|
+
self, *, query: Optional[str] = None, limit: int = 50, offset: int = 0
|
|
386
|
+
) -> PageList:
|
|
387
|
+
"""Browse pages; ``query`` is a case-insensitive substring filter
|
|
388
|
+
(use :meth:`search` for semantic search)."""
|
|
389
|
+
return self._call(self._op_get_all(query, limit, offset))
|
|
390
|
+
|
|
391
|
+
def update(self, id: str, text: str) -> None:
|
|
392
|
+
"""Replace a page's text. Needs a server with REST writes (task 0073)."""
|
|
393
|
+
self._call(self._op_update(id, text))
|
|
394
|
+
|
|
395
|
+
def delete(self, id: str) -> None:
|
|
396
|
+
"""Delete a page. Needs a server with REST writes (task 0073)."""
|
|
397
|
+
self._call(self._op_delete(id))
|
|
398
|
+
|
|
399
|
+
def relink(self) -> Dict[str, Any]:
|
|
400
|
+
"""Re-run the batch linking pass (do it after adding several
|
|
401
|
+
memories). Needs a server with REST writes (task 0073)."""
|
|
402
|
+
return self._call(self._op_relink())
|
|
403
|
+
|
|
404
|
+
# --- account ---
|
|
405
|
+
|
|
406
|
+
def me(self) -> Account:
|
|
407
|
+
"""The account this key belongs to (a cheap way to validate a key)."""
|
|
408
|
+
return self._call(self._op_me())
|
|
409
|
+
|
|
410
|
+
def usage(
|
|
411
|
+
self,
|
|
412
|
+
*,
|
|
413
|
+
group_by: str = "day",
|
|
414
|
+
since: Optional[DateLike] = None,
|
|
415
|
+
until: Optional[DateLike] = None,
|
|
416
|
+
) -> Usage:
|
|
417
|
+
"""Your own API usage, aggregated by ``day``, ``key`` or ``operation``."""
|
|
418
|
+
return self._call(self._op_usage(group_by, since, until))
|
|
419
|
+
|
|
420
|
+
# --- graph ---
|
|
421
|
+
|
|
422
|
+
def graph(self, *, limit: Optional[int] = None) -> Graph:
|
|
423
|
+
"""The link graph over your pages (capped; see ``truncated``)."""
|
|
424
|
+
return self._call(self._op_graph(limit))
|
|
425
|
+
|
|
426
|
+
def neighbors(self, id: str, *, depth: int = 1, limit: Optional[int] = None) -> Graph:
|
|
427
|
+
"""N-hop neighborhood (``depth`` 1–3) of one page."""
|
|
428
|
+
return self._call(self._op_neighbors(id, depth, limit))
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
class AsyncCortexClient(_Base):
|
|
432
|
+
"""Asynchronous twin of :class:`CortexClient` (same methods, ``await`` them)."""
|
|
433
|
+
|
|
434
|
+
def __init__(
|
|
435
|
+
self,
|
|
436
|
+
api_key: Optional[str] = None,
|
|
437
|
+
base_url: Optional[str] = None,
|
|
438
|
+
*,
|
|
439
|
+
timeout: float = 30.0,
|
|
440
|
+
max_retries: int = 2,
|
|
441
|
+
retry_backoff: float = 0.5,
|
|
442
|
+
http_client: Optional[httpx.AsyncClient] = None,
|
|
443
|
+
) -> None:
|
|
444
|
+
super().__init__(
|
|
445
|
+
api_key, base_url, timeout=timeout,
|
|
446
|
+
max_retries=max_retries, retry_backoff=retry_backoff,
|
|
447
|
+
)
|
|
448
|
+
self._owns_http = http_client is None
|
|
449
|
+
self._http = http_client or httpx.AsyncClient(timeout=timeout)
|
|
450
|
+
|
|
451
|
+
async def aclose(self) -> None:
|
|
452
|
+
if self._owns_http:
|
|
453
|
+
await self._http.aclose()
|
|
454
|
+
|
|
455
|
+
async def __aenter__(self) -> "AsyncCortexClient":
|
|
456
|
+
return self
|
|
457
|
+
|
|
458
|
+
async def __aexit__(self, *exc: Any) -> None:
|
|
459
|
+
await self.aclose()
|
|
460
|
+
|
|
461
|
+
async def _call(self, op: _Op) -> Any:
|
|
462
|
+
attempt = 0
|
|
463
|
+
while True:
|
|
464
|
+
try:
|
|
465
|
+
resp = await self._http.request(
|
|
466
|
+
op.method, self._url(op), headers=self._headers(),
|
|
467
|
+
params=op.params, json=op.json, timeout=self._timeout,
|
|
468
|
+
)
|
|
469
|
+
except httpx.HTTPError as e:
|
|
470
|
+
if op.retryable and attempt < self._max_retries:
|
|
471
|
+
await asyncio.sleep(self._delay(attempt))
|
|
472
|
+
attempt += 1
|
|
473
|
+
continue
|
|
474
|
+
raise ConnectionError(
|
|
475
|
+
f"Could not reach {self._base_url}: {e.__class__.__name__}: {e}"
|
|
476
|
+
) from e
|
|
477
|
+
if resp.status_code in _RETRY_STATUS and op.retryable and attempt < self._max_retries:
|
|
478
|
+
await asyncio.sleep(self._delay(attempt))
|
|
479
|
+
attempt += 1
|
|
480
|
+
continue
|
|
481
|
+
return self._finish(op, resp)
|
|
482
|
+
|
|
483
|
+
async def add(self, text: str, *, timestamp: Optional[str] = None) -> AddResult:
|
|
484
|
+
return await self._call(self._op_add(text, timestamp))
|
|
485
|
+
|
|
486
|
+
async def search(
|
|
487
|
+
self, query: str, *, limit: int = 4, expand_links: bool = True
|
|
488
|
+
) -> List[SearchResult]:
|
|
489
|
+
return await self._call(self._op_search(query, limit, expand_links))
|
|
490
|
+
|
|
491
|
+
async def get(self, id: str) -> Page:
|
|
492
|
+
return await self._call(self._op_get(id))
|
|
493
|
+
|
|
494
|
+
async def get_all(
|
|
495
|
+
self, *, query: Optional[str] = None, limit: int = 50, offset: int = 0
|
|
496
|
+
) -> PageList:
|
|
497
|
+
return await self._call(self._op_get_all(query, limit, offset))
|
|
498
|
+
|
|
499
|
+
async def update(self, id: str, text: str) -> None:
|
|
500
|
+
await self._call(self._op_update(id, text))
|
|
501
|
+
|
|
502
|
+
async def delete(self, id: str) -> None:
|
|
503
|
+
await self._call(self._op_delete(id))
|
|
504
|
+
|
|
505
|
+
async def relink(self) -> Dict[str, Any]:
|
|
506
|
+
return await self._call(self._op_relink())
|
|
507
|
+
|
|
508
|
+
async def me(self) -> Account:
|
|
509
|
+
return await self._call(self._op_me())
|
|
510
|
+
|
|
511
|
+
async def usage(
|
|
512
|
+
self,
|
|
513
|
+
*,
|
|
514
|
+
group_by: str = "day",
|
|
515
|
+
since: Optional[DateLike] = None,
|
|
516
|
+
until: Optional[DateLike] = None,
|
|
517
|
+
) -> Usage:
|
|
518
|
+
return await self._call(self._op_usage(group_by, since, until))
|
|
519
|
+
|
|
520
|
+
async def graph(self, *, limit: Optional[int] = None) -> Graph:
|
|
521
|
+
return await self._call(self._op_graph(limit))
|
|
522
|
+
|
|
523
|
+
async def neighbors(
|
|
524
|
+
self, id: str, *, depth: int = 1, limit: Optional[int] = None
|
|
525
|
+
) -> Graph:
|
|
526
|
+
return await self._call(self._op_neighbors(id, depth, limit))
|
cortexlayer/errors.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Typed errors for the Cortex client.
|
|
2
|
+
|
|
3
|
+
The server answers errors as ``{"error": "<message or code>"}`` with an HTTP
|
|
4
|
+
status; each status maps to one exception class, all subclasses of
|
|
5
|
+
:class:`CortexError`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CortexError(Exception):
|
|
14
|
+
"""Base class for every error this package raises."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, message: str, *, status: Optional[int] = None) -> None:
|
|
17
|
+
super().__init__(message)
|
|
18
|
+
self.message = message
|
|
19
|
+
self.status = status
|
|
20
|
+
|
|
21
|
+
def __str__(self) -> str:
|
|
22
|
+
return self.message
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class CortexConfigError(CortexError, ValueError):
|
|
26
|
+
"""The client was constructed with missing/invalid configuration."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class InvalidRequestError(CortexError, ValueError):
|
|
30
|
+
"""Bad arguments — caught client-side, or a server 400."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class AuthenticationError(CortexError):
|
|
34
|
+
"""401: the API key is missing, invalid, or revoked."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class PermissionDeniedError(CortexError):
|
|
38
|
+
"""403. ``code`` is the server's machine-readable reason when it sent one
|
|
39
|
+
(e.g. ``session_required``, ``not_invited``, ``email_not_verified``)."""
|
|
40
|
+
|
|
41
|
+
def __init__(self, message: str, *, status: Optional[int] = 403) -> None:
|
|
42
|
+
super().__init__(message, status=status)
|
|
43
|
+
self.code = message
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class NotFoundError(CortexError):
|
|
47
|
+
"""404: no such page (unknown ids and other users' ids look identical)."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class ConflictError(CortexError):
|
|
51
|
+
"""409: the request conflicts with current state (e.g. ``key_limit``)."""
|
|
52
|
+
|
|
53
|
+
def __init__(self, message: str, *, status: Optional[int] = 409) -> None:
|
|
54
|
+
super().__init__(message, status=status)
|
|
55
|
+
self.code = message
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class RateLimitError(CortexError):
|
|
59
|
+
"""429. ``retry_after`` is the ``Retry-After`` header in seconds, if sent."""
|
|
60
|
+
|
|
61
|
+
def __init__(
|
|
62
|
+
self,
|
|
63
|
+
message: str,
|
|
64
|
+
*,
|
|
65
|
+
status: Optional[int] = 429,
|
|
66
|
+
retry_after: Optional[float] = None,
|
|
67
|
+
) -> None:
|
|
68
|
+
super().__init__(message, status=status)
|
|
69
|
+
self.retry_after = retry_after
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class ServerError(CortexError):
|
|
73
|
+
"""5xx (or an unparseable server reply)."""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class ConnectionError(CortexError): # noqa: A001 — deliberate: mirrors httpx naming
|
|
77
|
+
"""The request never got a response (DNS, refused, timeout, TLS...)."""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class WritesNotSupportedError(CortexError):
|
|
81
|
+
"""The server is too old to have REST write endpoints (added in Cortex
|
|
82
|
+
server task 0073).
|
|
83
|
+
|
|
84
|
+
Raised when ``add`` / ``update`` / ``delete`` / ``relink`` get a 404/405
|
|
85
|
+
on the write route itself (as opposed to a 404 for an unknown page id).
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class LocalDependencyError(CortexError, ImportError):
|
|
90
|
+
"""The embedded engine needs packages that are not installed.
|
|
91
|
+
|
|
92
|
+
Install them with ``pip install "cortexlayer[local]"`` (Chroma + spaCy).
|
|
93
|
+
The hosted client (:class:`CortexClient`) never needs them.
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class MissingModelError(LocalDependencyError):
|
|
98
|
+
"""spaCy is installed but its language model is not.
|
|
99
|
+
|
|
100
|
+
Fix: ``python -m spacy download en_core_web_sm`` — or construct
|
|
101
|
+
``Memory(entity_extractor="regex")`` to run without a model.
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class LLMError(CortexError):
|
|
106
|
+
"""The language model needed for fact extraction / answering failed
|
|
107
|
+
(unreachable, timed out, or returned an error). Distinct from "the model
|
|
108
|
+
found nothing worth remembering", which is a normal empty result."""
|