parcle 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.
- parcle/__init__.py +79 -0
- parcle/client.py +527 -0
- parcle/exceptions.py +151 -0
- parcle/models.py +222 -0
- parcle/py.typed +0 -0
- parcle-0.1.0.dist-info/METADATA +91 -0
- parcle-0.1.0.dist-info/RECORD +9 -0
- parcle-0.1.0.dist-info/WHEEL +4 -0
- parcle-0.1.0.dist-info/licenses/LICENSE +21 -0
parcle/__init__.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Parcle — long-term memory for AI agents.
|
|
2
|
+
|
|
3
|
+
Ingest conversations and files into a per-user memory, then ask questions in
|
|
4
|
+
natural language and get cited answers back.
|
|
5
|
+
|
|
6
|
+
from parcle import Parcle
|
|
7
|
+
|
|
8
|
+
client = Parcle(api_key="pk_live_...")
|
|
9
|
+
client.ingest_dialog(user_id="ada", messages=[{"role": "user", "content": "..."}])
|
|
10
|
+
result = client.search(user_id="ada", query="What food should I avoid?")
|
|
11
|
+
print(result.answer, result.confidence, result.citations)
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from .client import Parcle
|
|
17
|
+
from .exceptions import (
|
|
18
|
+
AuthenticationError,
|
|
19
|
+
FileTooLargeError,
|
|
20
|
+
InternalServerError,
|
|
21
|
+
InvalidRequestError,
|
|
22
|
+
NotFoundError,
|
|
23
|
+
ParcleAPIError,
|
|
24
|
+
ParcleConfigError,
|
|
25
|
+
ParcleConnectionError,
|
|
26
|
+
ParcleError,
|
|
27
|
+
ParcleTimeoutError,
|
|
28
|
+
RateLimitError,
|
|
29
|
+
ServiceUnavailableError,
|
|
30
|
+
UnsupportedFileTypeError,
|
|
31
|
+
ValidationError,
|
|
32
|
+
)
|
|
33
|
+
from .models import (
|
|
34
|
+
Citation,
|
|
35
|
+
DeleteResult,
|
|
36
|
+
Event,
|
|
37
|
+
IngestDialogResult,
|
|
38
|
+
IngestFileResult,
|
|
39
|
+
Message,
|
|
40
|
+
SearchResult,
|
|
41
|
+
Session,
|
|
42
|
+
Source,
|
|
43
|
+
SourcesPage,
|
|
44
|
+
User,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
__version__ = "0.1.0"
|
|
48
|
+
|
|
49
|
+
__all__ = [
|
|
50
|
+
"Parcle",
|
|
51
|
+
"__version__",
|
|
52
|
+
# models
|
|
53
|
+
"Citation",
|
|
54
|
+
"DeleteResult",
|
|
55
|
+
"Event",
|
|
56
|
+
"IngestDialogResult",
|
|
57
|
+
"IngestFileResult",
|
|
58
|
+
"Message",
|
|
59
|
+
"SearchResult",
|
|
60
|
+
"Session",
|
|
61
|
+
"Source",
|
|
62
|
+
"SourcesPage",
|
|
63
|
+
"User",
|
|
64
|
+
# exceptions
|
|
65
|
+
"ParcleError",
|
|
66
|
+
"ParcleConfigError",
|
|
67
|
+
"ParcleConnectionError",
|
|
68
|
+
"ParcleTimeoutError",
|
|
69
|
+
"ParcleAPIError",
|
|
70
|
+
"InvalidRequestError",
|
|
71
|
+
"AuthenticationError",
|
|
72
|
+
"NotFoundError",
|
|
73
|
+
"FileTooLargeError",
|
|
74
|
+
"UnsupportedFileTypeError",
|
|
75
|
+
"ValidationError",
|
|
76
|
+
"RateLimitError",
|
|
77
|
+
"InternalServerError",
|
|
78
|
+
"ServiceUnavailableError",
|
|
79
|
+
]
|
parcle/client.py
ADDED
|
@@ -0,0 +1,527 @@
|
|
|
1
|
+
"""Synchronous client for the Parcle Memory API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import mimetypes
|
|
7
|
+
import os
|
|
8
|
+
import time
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Dict, IO, List, Mapping, Optional, Sequence, Tuple, Union
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
from .exceptions import (
|
|
15
|
+
ParcleAPIError,
|
|
16
|
+
ParcleConfigError,
|
|
17
|
+
ParcleConnectionError,
|
|
18
|
+
ParcleTimeoutError,
|
|
19
|
+
error_from_response,
|
|
20
|
+
)
|
|
21
|
+
from .models import (
|
|
22
|
+
DeleteResult,
|
|
23
|
+
Event,
|
|
24
|
+
IngestDialogResult,
|
|
25
|
+
IngestFileResult,
|
|
26
|
+
Message,
|
|
27
|
+
SearchResult,
|
|
28
|
+
Session,
|
|
29
|
+
Source,
|
|
30
|
+
SourcesPage,
|
|
31
|
+
User,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
__all__ = ["Parcle"]
|
|
35
|
+
|
|
36
|
+
DEFAULT_BASE_URL = "https://api.parcle.ai"
|
|
37
|
+
DEFAULT_TIMEOUT = 30.0
|
|
38
|
+
DEFAULT_MAX_RETRIES = 2
|
|
39
|
+
# Statuses worth retrying with backoff: rate limit, server error, unavailable.
|
|
40
|
+
_RETRY_STATUS = frozenset({429, 500, 503})
|
|
41
|
+
|
|
42
|
+
# Content types for the supported upload extensions, filling gaps left by the
|
|
43
|
+
# stdlib's ``mimetypes`` (which does not know markdown, for example).
|
|
44
|
+
_EXTRA_CONTENT_TYPES = {
|
|
45
|
+
".md": "text/markdown",
|
|
46
|
+
".markdown": "text/markdown",
|
|
47
|
+
".msg": "application/vnd.ms-outlook",
|
|
48
|
+
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
49
|
+
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
50
|
+
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
51
|
+
".xlsm": "application/vnd.ms-excel.sheet.macroEnabled.12",
|
|
52
|
+
".tsv": "text/tab-separated-values",
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
# What ``ingest_file`` accepts for ``file``: a path, a binary stream, raw bytes,
|
|
56
|
+
# or a ``(filename, content[, content_type])`` tuple.
|
|
57
|
+
FileInput = Union[
|
|
58
|
+
str,
|
|
59
|
+
os.PathLike,
|
|
60
|
+
IO[bytes],
|
|
61
|
+
bytes,
|
|
62
|
+
Tuple[str, Union[bytes, IO[bytes]]],
|
|
63
|
+
Tuple[str, Union[bytes, IO[bytes]], Optional[str]],
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
# A message passed to ``ingest_dialog``: a plain dict or a ``Message``.
|
|
67
|
+
MessageInput = Union[Mapping[str, Any], Message]
|
|
68
|
+
|
|
69
|
+
# A tag / tag_filter mapping.
|
|
70
|
+
TagFilter = Mapping[str, Any]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class Parcle:
|
|
74
|
+
"""Client for the Parcle Memory API.
|
|
75
|
+
|
|
76
|
+
Parameters
|
|
77
|
+
----------
|
|
78
|
+
api_key:
|
|
79
|
+
Your Parcle API key. If omitted, the ``PARCLE_API_KEY`` environment
|
|
80
|
+
variable is used.
|
|
81
|
+
base_url:
|
|
82
|
+
Override the API base URL (e.g. for a staging environment).
|
|
83
|
+
timeout:
|
|
84
|
+
Per-request timeout in seconds.
|
|
85
|
+
max_retries:
|
|
86
|
+
How many times to retry a request that fails with a retryable status
|
|
87
|
+
(429/500/503) or a connection error, using exponential backoff.
|
|
88
|
+
http_client:
|
|
89
|
+
Bring your own configured :class:`httpx.Client` (proxies, custom
|
|
90
|
+
transport, …). Its lifetime is then yours to manage.
|
|
91
|
+
|
|
92
|
+
The client may be used as a context manager to ensure the underlying
|
|
93
|
+
connection pool is closed::
|
|
94
|
+
|
|
95
|
+
with Parcle() as client:
|
|
96
|
+
client.search(user_id="ada", query="...")
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
def __init__(
|
|
100
|
+
self,
|
|
101
|
+
api_key: Optional[str] = None,
|
|
102
|
+
*,
|
|
103
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
104
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
105
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
106
|
+
http_client: Optional[httpx.Client] = None,
|
|
107
|
+
) -> None:
|
|
108
|
+
key = api_key or os.environ.get("PARCLE_API_KEY")
|
|
109
|
+
if not key:
|
|
110
|
+
raise ParcleConfigError(
|
|
111
|
+
"No API key provided. Pass api_key=... or set the "
|
|
112
|
+
"PARCLE_API_KEY environment variable."
|
|
113
|
+
)
|
|
114
|
+
self.api_key = key
|
|
115
|
+
self.base_url = base_url.rstrip("/")
|
|
116
|
+
self.max_retries = max(0, int(max_retries))
|
|
117
|
+
|
|
118
|
+
self._owns_client = http_client is None
|
|
119
|
+
self._client = http_client or httpx.Client(timeout=timeout)
|
|
120
|
+
|
|
121
|
+
# -- lifecycle -------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
def close(self) -> None:
|
|
124
|
+
"""Close the underlying HTTP client, if this instance owns it."""
|
|
125
|
+
if self._owns_client:
|
|
126
|
+
self._client.close()
|
|
127
|
+
|
|
128
|
+
def __enter__(self) -> "Parcle":
|
|
129
|
+
return self
|
|
130
|
+
|
|
131
|
+
def __exit__(self, *exc: Any) -> None:
|
|
132
|
+
self.close()
|
|
133
|
+
|
|
134
|
+
# -- users -----------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
def create_user(
|
|
137
|
+
self,
|
|
138
|
+
user_id: Optional[str] = None,
|
|
139
|
+
*,
|
|
140
|
+
name: Optional[str] = None,
|
|
141
|
+
timezone: Optional[str] = None,
|
|
142
|
+
) -> User:
|
|
143
|
+
"""Create or update a user.
|
|
144
|
+
|
|
145
|
+
Omit ``user_id`` to let Parcle generate one. ``timezone`` is an IANA
|
|
146
|
+
zone used only to interpret relative time in searches.
|
|
147
|
+
"""
|
|
148
|
+
payload = _drop_none(
|
|
149
|
+
{"user_id": user_id, "name": name, "timezone": timezone}
|
|
150
|
+
)
|
|
151
|
+
data = self._request("POST", "/v1/users", json_body=payload)
|
|
152
|
+
return User.from_dict(data)
|
|
153
|
+
|
|
154
|
+
# -- ingestion -------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
def ingest_dialog(
|
|
157
|
+
self,
|
|
158
|
+
user_id: str,
|
|
159
|
+
messages: Sequence[MessageInput],
|
|
160
|
+
*,
|
|
161
|
+
session_id: Optional[str] = None,
|
|
162
|
+
tag: Optional[TagFilter] = None,
|
|
163
|
+
) -> IngestDialogResult:
|
|
164
|
+
"""Append dialog messages to a user's memory.
|
|
165
|
+
|
|
166
|
+
Omit ``session_id`` to start a new session; pass one to append. ``tag``
|
|
167
|
+
applies only when a new session is created.
|
|
168
|
+
"""
|
|
169
|
+
payload: Dict[str, Any] = {
|
|
170
|
+
"user_id": user_id,
|
|
171
|
+
"session_id": session_id,
|
|
172
|
+
"messages": [_message_to_dict(m) for m in messages],
|
|
173
|
+
}
|
|
174
|
+
if tag is not None:
|
|
175
|
+
payload["tag"] = dict(tag)
|
|
176
|
+
data = self._request(
|
|
177
|
+
"POST", "/v1/memories/ingest_dialog", json_body=payload
|
|
178
|
+
)
|
|
179
|
+
return IngestDialogResult.from_dict(data)
|
|
180
|
+
|
|
181
|
+
def ingest_file(
|
|
182
|
+
self,
|
|
183
|
+
user_id: str,
|
|
184
|
+
file: FileInput,
|
|
185
|
+
*,
|
|
186
|
+
updated_at: Optional[str] = None,
|
|
187
|
+
tag: Optional[TagFilter] = None,
|
|
188
|
+
) -> IngestFileResult:
|
|
189
|
+
"""Upload a file into a user's memory.
|
|
190
|
+
|
|
191
|
+
``file`` may be a path, an open binary stream, raw ``bytes``, or a
|
|
192
|
+
``(filename, content[, content_type])`` tuple. For streams and bytes a
|
|
193
|
+
filename is required either via the stream's ``.name`` or the tuple form.
|
|
194
|
+
"""
|
|
195
|
+
filename, content, content_type = _prepare_file(file)
|
|
196
|
+
files = {"file": (filename, content, content_type)}
|
|
197
|
+
form: Dict[str, Any] = {"user_id": user_id}
|
|
198
|
+
if updated_at is not None:
|
|
199
|
+
form["updated_at"] = updated_at
|
|
200
|
+
if tag is not None:
|
|
201
|
+
form["tag"] = json.dumps(tag)
|
|
202
|
+
data = self._request(
|
|
203
|
+
"POST", "/v1/memories/ingest_files", files=files, data=form
|
|
204
|
+
)
|
|
205
|
+
return IngestFileResult.from_dict(data)
|
|
206
|
+
|
|
207
|
+
# -- events ----------------------------------------------------------------
|
|
208
|
+
|
|
209
|
+
def get_event(self, user_id: str, event_id: str) -> Event:
|
|
210
|
+
"""Fetch the ingestion status for a single write."""
|
|
211
|
+
data = self._request(
|
|
212
|
+
"POST",
|
|
213
|
+
"/v1/memories/events",
|
|
214
|
+
json_body={"user_id": user_id, "event_id": event_id},
|
|
215
|
+
)
|
|
216
|
+
return Event.from_dict(data)
|
|
217
|
+
|
|
218
|
+
def wait_until_ready(
|
|
219
|
+
self,
|
|
220
|
+
user_id: str,
|
|
221
|
+
event_id: str,
|
|
222
|
+
*,
|
|
223
|
+
poll_interval: float = 2.0,
|
|
224
|
+
timeout: Optional[float] = 120.0,
|
|
225
|
+
raise_on_failed: bool = True,
|
|
226
|
+
) -> Event:
|
|
227
|
+
"""Poll :meth:`get_event` until the event is ready or failed.
|
|
228
|
+
|
|
229
|
+
Returns the terminal :class:`~parcle.models.Event`. Raises
|
|
230
|
+
:class:`~parcle.exceptions.ParcleTimeoutError` if ``timeout`` seconds
|
|
231
|
+
pass first, and (by default) :class:`~parcle.exceptions.ParcleAPIError`
|
|
232
|
+
if ingestion failed.
|
|
233
|
+
"""
|
|
234
|
+
deadline = None if timeout is None else time.monotonic() + timeout
|
|
235
|
+
while True:
|
|
236
|
+
event = self.get_event(user_id, event_id)
|
|
237
|
+
if event.is_terminal:
|
|
238
|
+
if event.is_failed and raise_on_failed:
|
|
239
|
+
raise ParcleAPIError(
|
|
240
|
+
event.error or "Ingestion failed.",
|
|
241
|
+
code="ingestion_failed",
|
|
242
|
+
)
|
|
243
|
+
return event
|
|
244
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
245
|
+
raise ParcleTimeoutError(
|
|
246
|
+
f"Event {event_id!r} not ready after {timeout}s "
|
|
247
|
+
f"(last status: {event.status})."
|
|
248
|
+
)
|
|
249
|
+
time.sleep(poll_interval)
|
|
250
|
+
|
|
251
|
+
# -- search ----------------------------------------------------------------
|
|
252
|
+
|
|
253
|
+
def search(
|
|
254
|
+
self,
|
|
255
|
+
user_id: str,
|
|
256
|
+
query: str,
|
|
257
|
+
*,
|
|
258
|
+
tag_filter: Optional[TagFilter] = None,
|
|
259
|
+
timezone: Optional[str] = None,
|
|
260
|
+
) -> SearchResult:
|
|
261
|
+
"""Ask a natural-language question over a user's memory.
|
|
262
|
+
|
|
263
|
+
Returns an ``answer`` grounded in source ``citations``, with a
|
|
264
|
+
``confidence`` in ``[0, 1]``.
|
|
265
|
+
"""
|
|
266
|
+
payload = _drop_none(
|
|
267
|
+
{
|
|
268
|
+
"user_id": user_id,
|
|
269
|
+
"query": query,
|
|
270
|
+
"tag_filter": dict(tag_filter) if tag_filter is not None else None,
|
|
271
|
+
"timezone": timezone,
|
|
272
|
+
}
|
|
273
|
+
)
|
|
274
|
+
data = self._request("POST", "/v1/memories/search", json_body=payload)
|
|
275
|
+
return SearchResult.from_dict(data)
|
|
276
|
+
|
|
277
|
+
# -- sources & sessions ----------------------------------------------------
|
|
278
|
+
|
|
279
|
+
def list_sources(
|
|
280
|
+
self,
|
|
281
|
+
user_id: str,
|
|
282
|
+
*,
|
|
283
|
+
type: Optional[str] = None,
|
|
284
|
+
tag_filter: Optional[TagFilter] = None,
|
|
285
|
+
page: int = 1,
|
|
286
|
+
limit: int = 50,
|
|
287
|
+
order: str = "desc",
|
|
288
|
+
) -> SourcesPage:
|
|
289
|
+
"""List a user's sources (dialog sessions and files), page by page.
|
|
290
|
+
|
|
291
|
+
``type`` is ``"session"`` or ``"file"``; omit for both.
|
|
292
|
+
"""
|
|
293
|
+
payload = _drop_none(
|
|
294
|
+
{
|
|
295
|
+
"user_id": user_id,
|
|
296
|
+
"type": type,
|
|
297
|
+
"tag_filter": dict(tag_filter) if tag_filter is not None else None,
|
|
298
|
+
"page": page,
|
|
299
|
+
"limit": limit,
|
|
300
|
+
"order": order,
|
|
301
|
+
}
|
|
302
|
+
)
|
|
303
|
+
data = self._request("POST", "/v1/memories/sources", json_body=payload)
|
|
304
|
+
return SourcesPage.from_dict(data)
|
|
305
|
+
|
|
306
|
+
def iter_sources(
|
|
307
|
+
self,
|
|
308
|
+
user_id: str,
|
|
309
|
+
*,
|
|
310
|
+
type: Optional[str] = None,
|
|
311
|
+
tag_filter: Optional[TagFilter] = None,
|
|
312
|
+
limit: int = 50,
|
|
313
|
+
order: str = "desc",
|
|
314
|
+
):
|
|
315
|
+
"""Yield every source across all pages, fetching lazily."""
|
|
316
|
+
page = 1
|
|
317
|
+
while True:
|
|
318
|
+
result = self.list_sources(
|
|
319
|
+
user_id,
|
|
320
|
+
type=type,
|
|
321
|
+
tag_filter=tag_filter,
|
|
322
|
+
page=page,
|
|
323
|
+
limit=limit,
|
|
324
|
+
order=order,
|
|
325
|
+
)
|
|
326
|
+
for source in result.sources:
|
|
327
|
+
yield source
|
|
328
|
+
if page >= result.total_pages or not result.sources:
|
|
329
|
+
return
|
|
330
|
+
page += 1
|
|
331
|
+
|
|
332
|
+
def get_session(self, user_id: str, session_id: str) -> Session:
|
|
333
|
+
"""Read a dialog session's original messages in chronological order."""
|
|
334
|
+
data = self._request(
|
|
335
|
+
"POST",
|
|
336
|
+
"/v1/memories/sessions",
|
|
337
|
+
json_body={"user_id": user_id, "session_id": session_id},
|
|
338
|
+
)
|
|
339
|
+
return Session.from_dict(data)
|
|
340
|
+
|
|
341
|
+
# -- deletion --------------------------------------------------------------
|
|
342
|
+
|
|
343
|
+
def delete_by_session(self, user_id: str, session_id: str) -> DeleteResult:
|
|
344
|
+
"""Delete all memory derived from a dialog session."""
|
|
345
|
+
return self._delete(
|
|
346
|
+
"/v1/memories/by_session",
|
|
347
|
+
{"user_id": user_id, "session_id": session_id},
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
def delete_by_file(self, user_id: str, file_id: str) -> DeleteResult:
|
|
351
|
+
"""Delete all memory derived from a file."""
|
|
352
|
+
return self._delete(
|
|
353
|
+
"/v1/memories/by_file",
|
|
354
|
+
{"user_id": user_id, "file_id": file_id},
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
def delete_by_tag(self, user_id: str, tag_filter: TagFilter) -> DeleteResult:
|
|
358
|
+
"""Delete all memory whose source tags match ``tag_filter``.
|
|
359
|
+
|
|
360
|
+
``tag_filter`` must be non-empty.
|
|
361
|
+
"""
|
|
362
|
+
if not tag_filter:
|
|
363
|
+
raise ParcleConfigError("delete_by_tag requires a non-empty tag_filter.")
|
|
364
|
+
return self._delete(
|
|
365
|
+
"/v1/memories/by_tag",
|
|
366
|
+
{"user_id": user_id, "tag_filter": dict(tag_filter)},
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
def _delete(self, path: str, body: Dict[str, Any]) -> DeleteResult:
|
|
370
|
+
data = self._request("DELETE", path, json_body=body)
|
|
371
|
+
return DeleteResult.from_dict(data)
|
|
372
|
+
|
|
373
|
+
# -- transport -------------------------------------------------------------
|
|
374
|
+
|
|
375
|
+
def _request(
|
|
376
|
+
self,
|
|
377
|
+
method: str,
|
|
378
|
+
path: str,
|
|
379
|
+
*,
|
|
380
|
+
json_body: Optional[Dict[str, Any]] = None,
|
|
381
|
+
data: Optional[Dict[str, Any]] = None,
|
|
382
|
+
files: Optional[Dict[str, Any]] = None,
|
|
383
|
+
) -> Dict[str, Any]:
|
|
384
|
+
url = f"{self.base_url}{path}"
|
|
385
|
+
headers = {
|
|
386
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
387
|
+
"Accept": "application/json",
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
last_exc: Optional[Exception] = None
|
|
391
|
+
for attempt in range(self.max_retries + 1):
|
|
392
|
+
try:
|
|
393
|
+
response = self._client.request(
|
|
394
|
+
method,
|
|
395
|
+
url,
|
|
396
|
+
headers=headers,
|
|
397
|
+
json=json_body,
|
|
398
|
+
data=data,
|
|
399
|
+
files=files,
|
|
400
|
+
)
|
|
401
|
+
except httpx.TimeoutException as exc:
|
|
402
|
+
last_exc = ParcleConnectionError(f"Request to {url} timed out.")
|
|
403
|
+
last_exc.__cause__ = exc
|
|
404
|
+
except httpx.HTTPError as exc:
|
|
405
|
+
last_exc = ParcleConnectionError(f"Request to {url} failed: {exc}")
|
|
406
|
+
last_exc.__cause__ = exc
|
|
407
|
+
else:
|
|
408
|
+
if response.status_code in _RETRY_STATUS and attempt < self.max_retries:
|
|
409
|
+
self._sleep_for_retry(response, attempt)
|
|
410
|
+
continue
|
|
411
|
+
return self._parse_response(response)
|
|
412
|
+
|
|
413
|
+
# Reached only on a connection-level failure; back off and retry.
|
|
414
|
+
if attempt < self.max_retries:
|
|
415
|
+
time.sleep(_backoff_seconds(attempt))
|
|
416
|
+
continue
|
|
417
|
+
assert last_exc is not None
|
|
418
|
+
raise last_exc
|
|
419
|
+
|
|
420
|
+
# Unreachable, but keeps type-checkers happy.
|
|
421
|
+
assert last_exc is not None
|
|
422
|
+
raise last_exc
|
|
423
|
+
|
|
424
|
+
def _parse_response(self, response: httpx.Response) -> Dict[str, Any]:
|
|
425
|
+
body: Any
|
|
426
|
+
try:
|
|
427
|
+
body = response.json() if response.content else None
|
|
428
|
+
except (json.JSONDecodeError, ValueError):
|
|
429
|
+
body = None
|
|
430
|
+
|
|
431
|
+
if response.is_success:
|
|
432
|
+
return body if isinstance(body, dict) else {}
|
|
433
|
+
|
|
434
|
+
raise error_from_response(
|
|
435
|
+
response.status_code,
|
|
436
|
+
body,
|
|
437
|
+
fallback_message=(
|
|
438
|
+
response.text or f"Request failed with status {response.status_code}."
|
|
439
|
+
),
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
@staticmethod
|
|
443
|
+
def _sleep_for_retry(response: httpx.Response, attempt: int) -> None:
|
|
444
|
+
retry_after = response.headers.get("Retry-After")
|
|
445
|
+
if retry_after:
|
|
446
|
+
try:
|
|
447
|
+
time.sleep(float(retry_after))
|
|
448
|
+
return
|
|
449
|
+
except ValueError:
|
|
450
|
+
pass
|
|
451
|
+
time.sleep(_backoff_seconds(attempt))
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
# -- module-level helpers ------------------------------------------------------
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _drop_none(d: Dict[str, Any]) -> Dict[str, Any]:
|
|
458
|
+
"""Drop keys whose value is None so they are omitted from the request."""
|
|
459
|
+
return {k: v for k, v in d.items() if v is not None}
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _backoff_seconds(attempt: int) -> float:
|
|
463
|
+
"""Exponential backoff: 0.5s, 1s, 2s, … capped at 8s."""
|
|
464
|
+
return min(0.5 * (2 ** attempt), 8.0)
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _message_to_dict(message: MessageInput) -> Dict[str, Any]:
|
|
468
|
+
if isinstance(message, Message):
|
|
469
|
+
return message.to_dict()
|
|
470
|
+
if isinstance(message, Mapping):
|
|
471
|
+
if "role" not in message or "content" not in message:
|
|
472
|
+
raise ParcleConfigError(
|
|
473
|
+
"Each message requires 'role' and 'content' keys."
|
|
474
|
+
)
|
|
475
|
+
return _drop_none(dict(message))
|
|
476
|
+
raise TypeError(
|
|
477
|
+
f"Message must be a dict or Message, got {type(message).__name__}."
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _guess_content_type(filename: str) -> str:
|
|
482
|
+
suffix = Path(filename).suffix.lower()
|
|
483
|
+
if suffix in _EXTRA_CONTENT_TYPES:
|
|
484
|
+
return _EXTRA_CONTENT_TYPES[suffix]
|
|
485
|
+
guessed, _ = mimetypes.guess_type(filename)
|
|
486
|
+
return guessed or "application/octet-stream"
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def _prepare_file(
|
|
490
|
+
file: FileInput,
|
|
491
|
+
) -> Tuple[str, Union[bytes, IO[bytes]], str]:
|
|
492
|
+
"""Normalise the ``file`` argument into ``(filename, content, content_type)``."""
|
|
493
|
+
# (filename, content) or (filename, content, content_type)
|
|
494
|
+
if isinstance(file, tuple):
|
|
495
|
+
if len(file) == 2:
|
|
496
|
+
filename, content = file
|
|
497
|
+
content_type = None
|
|
498
|
+
elif len(file) == 3:
|
|
499
|
+
filename, content, content_type = file
|
|
500
|
+
else:
|
|
501
|
+
raise ParcleConfigError(
|
|
502
|
+
"file tuple must be (filename, content[, content_type])."
|
|
503
|
+
)
|
|
504
|
+
return filename, content, content_type or _guess_content_type(filename)
|
|
505
|
+
|
|
506
|
+
# Path-like → open and read.
|
|
507
|
+
if isinstance(file, (str, os.PathLike)):
|
|
508
|
+
path = Path(file)
|
|
509
|
+
if not path.is_file():
|
|
510
|
+
raise ParcleConfigError(f"File not found: {path}")
|
|
511
|
+
return path.name, path.read_bytes(), _guess_content_type(path.name)
|
|
512
|
+
|
|
513
|
+
# Raw bytes with no filename — we can't infer one.
|
|
514
|
+
if isinstance(file, (bytes, bytearray)):
|
|
515
|
+
raise ParcleConfigError(
|
|
516
|
+
"Raw bytes need a filename; pass a (filename, bytes) tuple instead."
|
|
517
|
+
)
|
|
518
|
+
|
|
519
|
+
# Assume a binary stream; derive the filename from .name if present.
|
|
520
|
+
name = getattr(file, "name", None)
|
|
521
|
+
if not name:
|
|
522
|
+
raise ParcleConfigError(
|
|
523
|
+
"Could not determine a filename for the file stream; pass a "
|
|
524
|
+
"(filename, stream) tuple instead."
|
|
525
|
+
)
|
|
526
|
+
filename = os.path.basename(name)
|
|
527
|
+
return filename, file, _guess_content_type(filename)
|
parcle/exceptions.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Exception hierarchy for the Parcle client.
|
|
2
|
+
|
|
3
|
+
Every non-2xx API response is translated into a :class:`ParcleAPIError`
|
|
4
|
+
subclass chosen by HTTP status. The server's stable ``error.code`` string and
|
|
5
|
+
human-readable ``error.message`` are preserved on the exception, along with the
|
|
6
|
+
``request_id`` for support correlation.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any, Dict, Optional
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ParcleError(Exception):
|
|
15
|
+
"""Base class for every error raised by this library."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ParcleConfigError(ParcleError):
|
|
19
|
+
"""Raised for misconfiguration, e.g. a missing API key."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ParcleConnectionError(ParcleError):
|
|
23
|
+
"""Raised when the request never produced an HTTP response.
|
|
24
|
+
|
|
25
|
+
Covers connection failures, DNS errors, and timeouts.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ParcleTimeoutError(ParcleError):
|
|
30
|
+
"""Raised when a polling helper exceeds its deadline."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ParcleAPIError(ParcleError):
|
|
34
|
+
"""Raised when the API returns a non-2xx response.
|
|
35
|
+
|
|
36
|
+
Attributes
|
|
37
|
+
----------
|
|
38
|
+
status_code:
|
|
39
|
+
The HTTP status code of the response.
|
|
40
|
+
code:
|
|
41
|
+
The stable ``error.code`` string for programmatic handling.
|
|
42
|
+
message:
|
|
43
|
+
The human-readable ``error.message``.
|
|
44
|
+
request_id:
|
|
45
|
+
The server-assigned request id, useful for support.
|
|
46
|
+
body:
|
|
47
|
+
The raw decoded response body, when available.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
message: str,
|
|
53
|
+
*,
|
|
54
|
+
status_code: Optional[int] = None,
|
|
55
|
+
code: Optional[str] = None,
|
|
56
|
+
request_id: Optional[str] = None,
|
|
57
|
+
body: Optional[Any] = None,
|
|
58
|
+
) -> None:
|
|
59
|
+
super().__init__(message)
|
|
60
|
+
self.status_code = status_code
|
|
61
|
+
self.code = code
|
|
62
|
+
self.message = message
|
|
63
|
+
self.request_id = request_id
|
|
64
|
+
self.body = body
|
|
65
|
+
|
|
66
|
+
def __str__(self) -> str:
|
|
67
|
+
parts = []
|
|
68
|
+
if self.status_code is not None:
|
|
69
|
+
parts.append(f"HTTP {self.status_code}")
|
|
70
|
+
if self.code:
|
|
71
|
+
parts.append(self.code)
|
|
72
|
+
prefix = f"[{' '.join(parts)}] " if parts else ""
|
|
73
|
+
suffix = f" (request_id={self.request_id})" if self.request_id else ""
|
|
74
|
+
return f"{prefix}{self.message}{suffix}"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# -- Status-specific subclasses ------------------------------------------------
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class InvalidRequestError(ParcleAPIError):
|
|
81
|
+
"""HTTP 400 — malformed JSON, wrong field type, or missing required field."""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class AuthenticationError(ParcleAPIError):
|
|
85
|
+
"""HTTP 401 — missing or invalid bearer key."""
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class NotFoundError(ParcleAPIError):
|
|
89
|
+
"""HTTP 404 — unknown user, session, file, or event."""
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class FileTooLargeError(ParcleAPIError):
|
|
93
|
+
"""HTTP 413 — uploaded file exceeds the size limit."""
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class UnsupportedFileTypeError(ParcleAPIError):
|
|
97
|
+
"""HTTP 415 — unsupported extension or database-like file."""
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class ValidationError(ParcleAPIError):
|
|
101
|
+
"""HTTP 422 — well-formed request that breaks a rule."""
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class RateLimitError(ParcleAPIError):
|
|
105
|
+
"""HTTP 429 — too many requests."""
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class InternalServerError(ParcleAPIError):
|
|
109
|
+
"""HTTP 500 — unexpected server failure."""
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class ServiceUnavailableError(ParcleAPIError):
|
|
113
|
+
"""HTTP 503 — temporarily overloaded or down."""
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
_STATUS_TO_EXCEPTION: Dict[int, type] = {
|
|
117
|
+
400: InvalidRequestError,
|
|
118
|
+
401: AuthenticationError,
|
|
119
|
+
404: NotFoundError,
|
|
120
|
+
413: FileTooLargeError,
|
|
121
|
+
415: UnsupportedFileTypeError,
|
|
122
|
+
422: ValidationError,
|
|
123
|
+
429: RateLimitError,
|
|
124
|
+
500: InternalServerError,
|
|
125
|
+
503: ServiceUnavailableError,
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def error_from_response(
|
|
130
|
+
status_code: int, body: Optional[Any], *, fallback_message: str
|
|
131
|
+
) -> ParcleAPIError:
|
|
132
|
+
"""Build the appropriate :class:`ParcleAPIError` for a failed response."""
|
|
133
|
+
code: Optional[str] = None
|
|
134
|
+
message = fallback_message
|
|
135
|
+
request_id: Optional[str] = None
|
|
136
|
+
|
|
137
|
+
if isinstance(body, dict):
|
|
138
|
+
err = body.get("error")
|
|
139
|
+
if isinstance(err, dict):
|
|
140
|
+
code = err.get("code")
|
|
141
|
+
message = err.get("message") or message
|
|
142
|
+
request_id = err.get("request_id")
|
|
143
|
+
|
|
144
|
+
exc_cls = _STATUS_TO_EXCEPTION.get(status_code, ParcleAPIError)
|
|
145
|
+
return exc_cls(
|
|
146
|
+
message,
|
|
147
|
+
status_code=status_code,
|
|
148
|
+
code=code,
|
|
149
|
+
request_id=request_id,
|
|
150
|
+
body=body,
|
|
151
|
+
)
|
parcle/models.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""Typed response models returned by the Parcle client.
|
|
2
|
+
|
|
3
|
+
These are lightweight dataclasses parsed from the API's JSON responses. Unknown
|
|
4
|
+
fields are ignored, so the models stay forward-compatible as the API grows.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import Any, Dict, List, Optional
|
|
11
|
+
|
|
12
|
+
# A free-form tag attached to a source: a flat mapping of string keys to scalar
|
|
13
|
+
# values used for grouping and filtering memory within one user.
|
|
14
|
+
Tag = Dict[str, Any]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class User:
|
|
19
|
+
"""A memory namespace returned by :meth:`Parcle.create_user`."""
|
|
20
|
+
|
|
21
|
+
user_id: str
|
|
22
|
+
name: Optional[str] = None
|
|
23
|
+
timezone: str = "UTC"
|
|
24
|
+
is_new: bool = False
|
|
25
|
+
|
|
26
|
+
@classmethod
|
|
27
|
+
def from_dict(cls, data: Dict[str, Any]) -> "User":
|
|
28
|
+
return cls(
|
|
29
|
+
user_id=data["user_id"],
|
|
30
|
+
name=data.get("name"),
|
|
31
|
+
timezone=data.get("timezone", "UTC"),
|
|
32
|
+
is_new=bool(data.get("is_new", False)),
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class Message:
|
|
38
|
+
"""A single dialog message, in or out."""
|
|
39
|
+
|
|
40
|
+
role: str
|
|
41
|
+
content: str
|
|
42
|
+
speaker: Optional[str] = None
|
|
43
|
+
updated_at: Optional[str] = None
|
|
44
|
+
|
|
45
|
+
@classmethod
|
|
46
|
+
def from_dict(cls, data: Dict[str, Any]) -> "Message":
|
|
47
|
+
return cls(
|
|
48
|
+
role=data["role"],
|
|
49
|
+
content=data["content"],
|
|
50
|
+
speaker=data.get("speaker"),
|
|
51
|
+
updated_at=data.get("updated_at"),
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
55
|
+
out: Dict[str, Any] = {"role": self.role, "content": self.content}
|
|
56
|
+
if self.speaker is not None:
|
|
57
|
+
out["speaker"] = self.speaker
|
|
58
|
+
if self.updated_at is not None:
|
|
59
|
+
out["updated_at"] = self.updated_at
|
|
60
|
+
return out
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class IngestDialogResult:
|
|
65
|
+
"""Result of :meth:`Parcle.ingest_dialog`."""
|
|
66
|
+
|
|
67
|
+
session_id: str
|
|
68
|
+
event_id: str
|
|
69
|
+
|
|
70
|
+
@classmethod
|
|
71
|
+
def from_dict(cls, data: Dict[str, Any]) -> "IngestDialogResult":
|
|
72
|
+
return cls(session_id=data["session_id"], event_id=data["event_id"])
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass
|
|
76
|
+
class IngestFileResult:
|
|
77
|
+
"""Result of :meth:`Parcle.ingest_file`."""
|
|
78
|
+
|
|
79
|
+
file_id: str
|
|
80
|
+
event_id: str
|
|
81
|
+
|
|
82
|
+
@classmethod
|
|
83
|
+
def from_dict(cls, data: Dict[str, Any]) -> "IngestFileResult":
|
|
84
|
+
return cls(file_id=data["file_id"], event_id=data["event_id"])
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass
|
|
88
|
+
class Event:
|
|
89
|
+
"""Ingestion status for a single write, from :meth:`Parcle.get_event`."""
|
|
90
|
+
|
|
91
|
+
event_id: str
|
|
92
|
+
status: str
|
|
93
|
+
error: Optional[str] = None
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def is_ready(self) -> bool:
|
|
97
|
+
"""True once the ingested content is searchable."""
|
|
98
|
+
return self.status == "ready"
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def is_failed(self) -> bool:
|
|
102
|
+
return self.status == "failed"
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def is_terminal(self) -> bool:
|
|
106
|
+
"""True once the event will not change state again."""
|
|
107
|
+
return self.status in ("ready", "failed")
|
|
108
|
+
|
|
109
|
+
@classmethod
|
|
110
|
+
def from_dict(cls, data: Dict[str, Any]) -> "Event":
|
|
111
|
+
return cls(
|
|
112
|
+
event_id=data["event_id"],
|
|
113
|
+
status=data["status"],
|
|
114
|
+
error=data.get("error"),
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass
|
|
119
|
+
class Citation:
|
|
120
|
+
"""A source the search answer draws on."""
|
|
121
|
+
|
|
122
|
+
type: str # "session" | "file"
|
|
123
|
+
id: str
|
|
124
|
+
|
|
125
|
+
@classmethod
|
|
126
|
+
def from_dict(cls, data: Dict[str, Any]) -> "Citation":
|
|
127
|
+
return cls(type=data["type"], id=data["id"])
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclass
|
|
131
|
+
class SearchResult:
|
|
132
|
+
"""Result of :meth:`Parcle.search` — an answer grounded in citations."""
|
|
133
|
+
|
|
134
|
+
answer: str
|
|
135
|
+
confidence: float
|
|
136
|
+
citations: List[Citation] = field(default_factory=list)
|
|
137
|
+
|
|
138
|
+
@classmethod
|
|
139
|
+
def from_dict(cls, data: Dict[str, Any]) -> "SearchResult":
|
|
140
|
+
return cls(
|
|
141
|
+
answer=data["answer"],
|
|
142
|
+
confidence=float(data["confidence"]),
|
|
143
|
+
citations=[Citation.from_dict(c) for c in data.get("citations", [])],
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@dataclass
|
|
148
|
+
class Source:
|
|
149
|
+
"""A dialog session or file in a user's memory."""
|
|
150
|
+
|
|
151
|
+
id: str
|
|
152
|
+
type: str # "session" | "file"
|
|
153
|
+
updated_at: Optional[str] = None
|
|
154
|
+
tag: Optional[Tag] = None
|
|
155
|
+
name: Optional[str] = None # filename, files only
|
|
156
|
+
|
|
157
|
+
@classmethod
|
|
158
|
+
def from_dict(cls, data: Dict[str, Any]) -> "Source":
|
|
159
|
+
return cls(
|
|
160
|
+
id=data["id"],
|
|
161
|
+
type=data["type"],
|
|
162
|
+
updated_at=data.get("updated_at"),
|
|
163
|
+
tag=data.get("tag"),
|
|
164
|
+
name=data.get("name"),
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@dataclass
|
|
169
|
+
class SourcesPage:
|
|
170
|
+
"""One page of :meth:`Parcle.list_sources`."""
|
|
171
|
+
|
|
172
|
+
sources: List[Source]
|
|
173
|
+
page: int
|
|
174
|
+
total_pages: int
|
|
175
|
+
total: int
|
|
176
|
+
|
|
177
|
+
def __iter__(self):
|
|
178
|
+
return iter(self.sources)
|
|
179
|
+
|
|
180
|
+
def __len__(self) -> int:
|
|
181
|
+
return len(self.sources)
|
|
182
|
+
|
|
183
|
+
@classmethod
|
|
184
|
+
def from_dict(cls, data: Dict[str, Any]) -> "SourcesPage":
|
|
185
|
+
return cls(
|
|
186
|
+
sources=[Source.from_dict(s) for s in data.get("sources", [])],
|
|
187
|
+
page=int(data.get("page", 1)),
|
|
188
|
+
total_pages=int(data.get("total_pages", 1)),
|
|
189
|
+
total=int(data.get("total", 0)),
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@dataclass
|
|
194
|
+
class Session:
|
|
195
|
+
"""A dialog session's original messages, from :meth:`Parcle.get_session`."""
|
|
196
|
+
|
|
197
|
+
session_id: str
|
|
198
|
+
messages: List[Message]
|
|
199
|
+
tag: Optional[Tag] = None
|
|
200
|
+
|
|
201
|
+
@classmethod
|
|
202
|
+
def from_dict(cls, data: Dict[str, Any]) -> "Session":
|
|
203
|
+
return cls(
|
|
204
|
+
session_id=data["session_id"],
|
|
205
|
+
messages=[Message.from_dict(m) for m in data.get("messages", [])],
|
|
206
|
+
tag=data.get("tag"),
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
@dataclass
|
|
211
|
+
class DeleteResult:
|
|
212
|
+
"""Result of any ``delete_by_*`` call."""
|
|
213
|
+
|
|
214
|
+
deleted: bool
|
|
215
|
+
deleted_count: int
|
|
216
|
+
|
|
217
|
+
@classmethod
|
|
218
|
+
def from_dict(cls, data: Dict[str, Any]) -> "DeleteResult":
|
|
219
|
+
return cls(
|
|
220
|
+
deleted=bool(data["deleted"]),
|
|
221
|
+
deleted_count=int(data["deleted_count"]),
|
|
222
|
+
)
|
parcle/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: parcle
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Long-term memory for AI agents — a Python client for the Parcle Memory API.
|
|
5
|
+
Project-URL: Homepage, https://parcle.ai
|
|
6
|
+
Project-URL: Documentation, https://api.parcle.ai
|
|
7
|
+
Author: Parcle
|
|
8
|
+
License: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: agents,ai,llm,memory,parcle,rag
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Python: >=3.9
|
|
22
|
+
Requires-Dist: httpx>=0.24
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
25
|
+
Requires-Dist: respx>=0.20; extra == 'dev'
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
<div align="center">
|
|
29
|
+
|
|
30
|
+
# Parcle
|
|
31
|
+
|
|
32
|
+
**Long-term memory for AI agents**
|
|
33
|
+
|
|
34
|
+
Ingest conversations and files, then ask questions in natural language and get
|
|
35
|
+
cited answers back. Give every user a private, persistent agent memory.
|
|
36
|
+
|
|
37
|
+
</div>
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## Why Parcle?
|
|
42
|
+
|
|
43
|
+
LLMs forget everything between calls. Parcle gives every user a private memory you
|
|
44
|
+
can write to and search:
|
|
45
|
+
|
|
46
|
+
- 🧠 **Per-user memory** — scope everything to a `user_id`.
|
|
47
|
+
- 💬 **Ingest anything** — chat transcripts and files (PDF, Markdown, text, …) go in the same place.
|
|
48
|
+
- 🔎 **Ask, don't query** — search returns a synthesized **answer** with **citations**, not just raw chunks.
|
|
49
|
+
|
|
50
|
+
## Installation
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
pip install parcle
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Quickstart
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
from parcle import Parcle
|
|
60
|
+
|
|
61
|
+
# Reads PARCLE_API_KEY from the environment if api_key is omitted.
|
|
62
|
+
client = Parcle(api_key="pk_live_...")
|
|
63
|
+
|
|
64
|
+
# 1. Write a conversation into a user's memory.
|
|
65
|
+
# Ingestion is incremental: omit session_id to start a new session, then
|
|
66
|
+
# pass the returned session_id back to append more turns to the same one.
|
|
67
|
+
result = client.ingest_dialog(
|
|
68
|
+
user_id="ada",
|
|
69
|
+
messages=[
|
|
70
|
+
{"role": "user", "content": "I'm allergic to peanuts."},
|
|
71
|
+
{"role": "assistant", "content": "Got it — I'll avoid peanuts in suggestions."},
|
|
72
|
+
],
|
|
73
|
+
)
|
|
74
|
+
client.ingest_dialog(
|
|
75
|
+
user_id="ada",
|
|
76
|
+
session_id=result.session_id, # append to the same session
|
|
77
|
+
messages=[
|
|
78
|
+
{"role": "user", "content": "Also, I don't eat shellfish."},
|
|
79
|
+
],
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
# 2. ...or ingest a file (PDF, Markdown, text, …).
|
|
83
|
+
client.ingest_file(user_id="ada", file="diet-notes.pdf")
|
|
84
|
+
|
|
85
|
+
# 3. Ask a question. You get an answer with confidence and citations.
|
|
86
|
+
result = client.search(user_id="ada", query="What food should I avoid?")
|
|
87
|
+
|
|
88
|
+
print(result.answer) # "You're allergic to peanuts, so avoid them."
|
|
89
|
+
print(result.confidence) # 0.92
|
|
90
|
+
print(result.citations) # [Citation(type="session", id="...")]
|
|
91
|
+
```
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
parcle/__init__.py,sha256=KHPp1LaiZJYHKEyS4rrxKmafIPIzgzZ3TrSBBQNorrQ,1741
|
|
2
|
+
parcle/client.py,sha256=Bd5BmUN8RDEg6BJXCrggq-DEshWKMUCKYBt_9LuuEAM,17870
|
|
3
|
+
parcle/exceptions.py,sha256=PPyhyna7VHhR4JtgDP8ZrvsIqOQxpisBJvSN0fvUIBY,4226
|
|
4
|
+
parcle/models.py,sha256=Csq3tsCYONdUYDPscYZ14qgsPIiTtgWUniS2QSlew_s,5748
|
|
5
|
+
parcle/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
parcle-0.1.0.dist-info/METADATA,sha256=BUOzK3xp6-6tqz8it2AR-vBwA4JktHpGEZbTAOTR9fU,2893
|
|
7
|
+
parcle-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
8
|
+
parcle-0.1.0.dist-info/licenses/LICENSE,sha256=cEfzkpOGKfxoc-4jVvIUb9uIBejC__tKqou_kcOmYAs,1063
|
|
9
|
+
parcle-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Parcle
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|