behaviorgpt 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.
- behaviorgpt/__init__.py +29 -0
- behaviorgpt/_client.py +275 -0
- behaviorgpt/_exceptions.py +25 -0
- behaviorgpt/resources/__init__.py +0 -0
- behaviorgpt/resources/_shared.py +37 -0
- behaviorgpt/resources/catalogs.py +293 -0
- behaviorgpt/resources/recommendations.py +28 -0
- behaviorgpt/resources/requests.py +41 -0
- behaviorgpt/resources/search.py +28 -0
- behaviorgpt/types/__init__.py +59 -0
- behaviorgpt/types/domains.py +8 -0
- behaviorgpt/types/events.py +114 -0
- behaviorgpt/types/http.py +231 -0
- behaviorgpt-0.1.0.dist-info/METADATA +120 -0
- behaviorgpt-0.1.0.dist-info/RECORD +17 -0
- behaviorgpt-0.1.0.dist-info/WHEEL +4 -0
- behaviorgpt-0.1.0.dist-info/licenses/LICENSE +21 -0
behaviorgpt/__init__.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from behaviorgpt._client import UnboxAIClient
|
|
2
|
+
from behaviorgpt._exceptions import (
|
|
3
|
+
AuthenticationError,
|
|
4
|
+
BadRequestError,
|
|
5
|
+
RateLimitError,
|
|
6
|
+
UnboxAIError,
|
|
7
|
+
)
|
|
8
|
+
from behaviorgpt.resources.catalogs import ProgressPrinter
|
|
9
|
+
from behaviorgpt.types.events import (
|
|
10
|
+
AddToCart,
|
|
11
|
+
Order,
|
|
12
|
+
RemoveFromCart,
|
|
13
|
+
Search,
|
|
14
|
+
View,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"Search",
|
|
19
|
+
"AddToCart",
|
|
20
|
+
"View",
|
|
21
|
+
"RemoveFromCart",
|
|
22
|
+
"Order",
|
|
23
|
+
"UnboxAIClient",
|
|
24
|
+
"ProgressPrinter",
|
|
25
|
+
"UnboxAIError",
|
|
26
|
+
"AuthenticationError",
|
|
27
|
+
"BadRequestError",
|
|
28
|
+
"RateLimitError",
|
|
29
|
+
]
|
behaviorgpt/_client.py
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import random
|
|
3
|
+
import warnings
|
|
4
|
+
from collections import defaultdict
|
|
5
|
+
from datetime import UTC, datetime, timedelta
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Callable, Optional, Sequence, cast
|
|
8
|
+
from uuid import uuid4
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from behaviorgpt._exceptions import AuthenticationError
|
|
13
|
+
from behaviorgpt.resources.catalogs import Catalogs
|
|
14
|
+
from behaviorgpt.resources.recommendations import Recommender
|
|
15
|
+
from behaviorgpt.resources.requests import UnboxAIRequester
|
|
16
|
+
from behaviorgpt.resources.search import Searcher
|
|
17
|
+
from behaviorgpt.types import (
|
|
18
|
+
AddToCart,
|
|
19
|
+
CartItem,
|
|
20
|
+
Domains,
|
|
21
|
+
EmbedJobDetails,
|
|
22
|
+
Item,
|
|
23
|
+
JobStatus,
|
|
24
|
+
Order,
|
|
25
|
+
Search,
|
|
26
|
+
SessionEvent,
|
|
27
|
+
UnboxAIResponse,
|
|
28
|
+
UserEvent,
|
|
29
|
+
UserHistoryInput,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
# An embed upload streams the whole parquet, and the API answers only once the
|
|
33
|
+
# file is stored and queued: httpx's 5 s default is too short for both.
|
|
34
|
+
DEFAULT_HTTP_TIMEOUT = httpx.Timeout(120.0, connect=10.0, write=300.0)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class UnboxAIClient:
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
market: str,
|
|
41
|
+
*,
|
|
42
|
+
timezone: str = "UTC",
|
|
43
|
+
api_key: Optional[str] = None,
|
|
44
|
+
base_url: str = "https://behaviorgpt-northeurope.api.unboxai.com/v1",
|
|
45
|
+
default_catalog_id: str = "retail_catalog",
|
|
46
|
+
http_timeout: float | httpx.Timeout = DEFAULT_HTTP_TIMEOUT,
|
|
47
|
+
):
|
|
48
|
+
self.api_key = api_key or os.environ.get("UNBOXAI_API_KEY")
|
|
49
|
+
if not self.api_key:
|
|
50
|
+
msg = "API key is missing. Pass it or set UNBOXAI_API_KEY."
|
|
51
|
+
raise AuthenticationError(msg)
|
|
52
|
+
|
|
53
|
+
self.base_url = base_url
|
|
54
|
+
self.default_catalog_id = default_catalog_id
|
|
55
|
+
|
|
56
|
+
headers = {"x-api-key": self.api_key}
|
|
57
|
+
self._http_client = httpx.Client(
|
|
58
|
+
base_url=self.base_url, headers=headers, timeout=http_timeout
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
self.domains = Domains(market=market)
|
|
62
|
+
self.timezone = timezone
|
|
63
|
+
|
|
64
|
+
self.requester = UnboxAIRequester(domains=self.domains, timezone=self.timezone)
|
|
65
|
+
|
|
66
|
+
self.recommendations = Recommender(self._http_client)
|
|
67
|
+
self.search = Searcher(self._http_client)
|
|
68
|
+
self.catalogs = Catalogs(self._http_client)
|
|
69
|
+
|
|
70
|
+
def complete(
|
|
71
|
+
self,
|
|
72
|
+
history: UserHistoryInput,
|
|
73
|
+
limit: int = 10,
|
|
74
|
+
offset: int = 0,
|
|
75
|
+
*,
|
|
76
|
+
catalog_id: Optional[str] = None,
|
|
77
|
+
filters: Optional[dict[str, Any]] = None,
|
|
78
|
+
register_event: bool = False,
|
|
79
|
+
) -> UnboxAIResponse:
|
|
80
|
+
active_catalog: str = catalog_id or self.default_catalog_id
|
|
81
|
+
request_headers = {"x-catalog-id": active_catalog}
|
|
82
|
+
|
|
83
|
+
history_events = self.__from_sequence(active_catalog, history)
|
|
84
|
+
last_event = self._get_last_event(history)
|
|
85
|
+
|
|
86
|
+
if isinstance(last_event, Search):
|
|
87
|
+
req = self.requester.make(
|
|
88
|
+
query=last_event.query,
|
|
89
|
+
history=history_events[:-1],
|
|
90
|
+
catalog_id=active_catalog,
|
|
91
|
+
limit=limit,
|
|
92
|
+
offset=offset,
|
|
93
|
+
filters=filters,
|
|
94
|
+
register_event=register_event,
|
|
95
|
+
)
|
|
96
|
+
return self.search.create(payload=req, headers=request_headers)
|
|
97
|
+
|
|
98
|
+
req = self.requester.make(
|
|
99
|
+
history=history_events,
|
|
100
|
+
catalog_id=active_catalog,
|
|
101
|
+
limit=limit,
|
|
102
|
+
offset=offset,
|
|
103
|
+
filters=filters,
|
|
104
|
+
register_event=register_event,
|
|
105
|
+
)
|
|
106
|
+
return self.recommendations.create(payload=req, headers=request_headers)
|
|
107
|
+
|
|
108
|
+
def embed(
|
|
109
|
+
self,
|
|
110
|
+
path: Path | str,
|
|
111
|
+
wait: bool = False,
|
|
112
|
+
interval: float = 5.0,
|
|
113
|
+
timeout: float = 600.0,
|
|
114
|
+
on_progress: Optional[Callable[[JobStatus], None]] = None,
|
|
115
|
+
startup_grace: float = 120.0,
|
|
116
|
+
reference_time: Optional[datetime | str] = None,
|
|
117
|
+
) -> EmbedJobDetails:
|
|
118
|
+
"""Upload and embed a catalog parquet.
|
|
119
|
+
|
|
120
|
+
Pass `wait=True` to block until the embed job finishes. While waiting,
|
|
121
|
+
each new job state is printed once; pass `on_progress` to render
|
|
122
|
+
progress yourself, or `on_progress=lambda _: None` for silence.
|
|
123
|
+
|
|
124
|
+
`reference_time` is when item ages and sales are measured; the upload
|
|
125
|
+
time by default.
|
|
126
|
+
"""
|
|
127
|
+
if isinstance(path, str):
|
|
128
|
+
path = Path(path)
|
|
129
|
+
|
|
130
|
+
return self.catalogs.embed(
|
|
131
|
+
path,
|
|
132
|
+
wait=wait,
|
|
133
|
+
interval=interval,
|
|
134
|
+
timeout=timeout,
|
|
135
|
+
on_progress=on_progress,
|
|
136
|
+
startup_grace=startup_grace,
|
|
137
|
+
reference_time=reference_time,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
def job_status(self, job_id: str) -> JobStatus:
|
|
141
|
+
return self.catalogs.get_job_status(job_id)
|
|
142
|
+
|
|
143
|
+
def similar_items(
|
|
144
|
+
self,
|
|
145
|
+
item_id: str,
|
|
146
|
+
limit: int = 10,
|
|
147
|
+
offset: int = 0,
|
|
148
|
+
*,
|
|
149
|
+
catalog_id: Optional[str] = None,
|
|
150
|
+
) -> UnboxAIResponse:
|
|
151
|
+
active_catalog: str = catalog_id or self.default_catalog_id
|
|
152
|
+
request_headers = {"x-catalog-id": active_catalog}
|
|
153
|
+
|
|
154
|
+
return self.catalogs.get_similar_items(
|
|
155
|
+
item_id,
|
|
156
|
+
catalog_id=active_catalog,
|
|
157
|
+
limit=limit,
|
|
158
|
+
offset=offset,
|
|
159
|
+
headers=request_headers,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
def similar_products(
|
|
163
|
+
self, product_id: str, *args: Any, **kwargs: Any
|
|
164
|
+
) -> UnboxAIResponse:
|
|
165
|
+
warnings.warn(
|
|
166
|
+
"`similar_products` is deprecated, use `similar_items`",
|
|
167
|
+
DeprecationWarning,
|
|
168
|
+
stacklevel=2,
|
|
169
|
+
)
|
|
170
|
+
return self.similar_items(product_id, *args, **kwargs)
|
|
171
|
+
|
|
172
|
+
def random_item(self, catalog_id: Optional[str] = None, window: int = 100) -> Item:
|
|
173
|
+
"""One item from the embedded catalog, handy when
|
|
174
|
+
you have no item id at hand.
|
|
175
|
+
"""
|
|
176
|
+
top = self.complete(history=[], catalog_id=catalog_id, limit=window)
|
|
177
|
+
if not top.items:
|
|
178
|
+
active = catalog_id or self.default_catalog_id
|
|
179
|
+
raise ValueError(f"catalog {active!r} returned no items")
|
|
180
|
+
return random.choice(top.items)
|
|
181
|
+
|
|
182
|
+
def random_product(self, *args: Any, **kwargs: Any) -> Item:
|
|
183
|
+
warnings.warn(
|
|
184
|
+
"`random_product` is deprecated, use `random_item`",
|
|
185
|
+
DeprecationWarning,
|
|
186
|
+
stacklevel=2,
|
|
187
|
+
)
|
|
188
|
+
return self.random_item(*args, **kwargs)
|
|
189
|
+
|
|
190
|
+
def __from_sequence(
|
|
191
|
+
self, catalog_id: str, context: UserHistoryInput
|
|
192
|
+
) -> list[SessionEvent]:
|
|
193
|
+
events: list[SessionEvent] = []
|
|
194
|
+
|
|
195
|
+
if not context:
|
|
196
|
+
return events
|
|
197
|
+
|
|
198
|
+
# Track cart state within a single session
|
|
199
|
+
def _process_session_events(
|
|
200
|
+
session_events: Sequence[UserEvent], session_id: str, session_offset: int
|
|
201
|
+
):
|
|
202
|
+
cart_state: dict[str, int] = defaultdict(int)
|
|
203
|
+
current_cart_id = str(uuid4())
|
|
204
|
+
|
|
205
|
+
# Base time for the start of the sequence
|
|
206
|
+
base_time = datetime.now(tz=UTC)
|
|
207
|
+
|
|
208
|
+
for i, ev in enumerate(session_events):
|
|
209
|
+
if isinstance(ev, AddToCart):
|
|
210
|
+
cart_state[ev.item] += 1
|
|
211
|
+
elif isinstance(ev, Order):
|
|
212
|
+
if not getattr(ev, "items", None):
|
|
213
|
+
ev.cart_id = current_cart_id
|
|
214
|
+
ev.items = [
|
|
215
|
+
CartItem(item, qty) for item, qty in cart_state.items()
|
|
216
|
+
]
|
|
217
|
+
cart_state.clear()
|
|
218
|
+
current_cart_id = str(uuid4())
|
|
219
|
+
|
|
220
|
+
# Add 1 millisecond for each event to guarantee network-safe distinct
|
|
221
|
+
# ordering. Factors in a session offset just in case 2D arrays
|
|
222
|
+
# were passed
|
|
223
|
+
event_time = base_time + timedelta(
|
|
224
|
+
milliseconds=(session_offset * 1000) + i
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
events.append(
|
|
228
|
+
self.__to_session_evt(ev, session_id, catalog_id, event_time)
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
if isinstance(context[0], (list, tuple)):
|
|
232
|
+
context_2d = cast(Sequence[Sequence[UserEvent]], context)
|
|
233
|
+
for session_index, session_sequence in enumerate(context_2d):
|
|
234
|
+
_process_session_events(session_sequence, str(uuid4()), session_index)
|
|
235
|
+
else:
|
|
236
|
+
context_1d = cast(Sequence[UserEvent], context)
|
|
237
|
+
_process_session_events(context_1d, str(uuid4()), 0)
|
|
238
|
+
|
|
239
|
+
return events
|
|
240
|
+
|
|
241
|
+
def umap(self, *, catalog_id: Optional[str] = None):
|
|
242
|
+
"""The catalog's UMAP, a 2D map of its items, as an HTML page.
|
|
243
|
+
|
|
244
|
+
Maps exist only for catalogs of up to 100,000 items, and appear within
|
|
245
|
+
about a minute after the catalog is ready: laying out more items than
|
|
246
|
+
that does not fit in the embedding worker's memory, so larger catalogs
|
|
247
|
+
(such as the shared `retail_catalog`) never get one. A missing map
|
|
248
|
+
raises `UnboxAIError` with status 404 and says which case applies.
|
|
249
|
+
"""
|
|
250
|
+
active_catalog: str = catalog_id or self.default_catalog_id
|
|
251
|
+
return self.catalogs.get_umap(catalog_id=active_catalog)
|
|
252
|
+
|
|
253
|
+
def __to_session_evt(
|
|
254
|
+
self, event: UserEvent, session_id: str, catalog_id: str, timestamp: datetime
|
|
255
|
+
) -> SessionEvent:
|
|
256
|
+
return SessionEvent(
|
|
257
|
+
store_id=catalog_id,
|
|
258
|
+
domains=self.domains,
|
|
259
|
+
timezone=self.timezone,
|
|
260
|
+
event=event,
|
|
261
|
+
session_id=session_id,
|
|
262
|
+
timestamp=timestamp,
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
def _get_last_event(self, history: UserHistoryInput) -> UserEvent | None:
|
|
266
|
+
if not history:
|
|
267
|
+
return None
|
|
268
|
+
|
|
269
|
+
if isinstance(history[0], (list, tuple)):
|
|
270
|
+
history_2d = cast(Sequence[Sequence[UserEvent]], history)
|
|
271
|
+
inner_seq = history_2d[-1]
|
|
272
|
+
return inner_seq[-1] if inner_seq else None
|
|
273
|
+
else:
|
|
274
|
+
history_1d = cast(Sequence[UserEvent], history)
|
|
275
|
+
return history_1d[-1]
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
class UnboxAIError(Exception):
|
|
2
|
+
def __init__(
|
|
3
|
+
self, message: str, status_code: int | None = None, response: dict | None = None
|
|
4
|
+
):
|
|
5
|
+
super().__init__(message)
|
|
6
|
+
self.status_code = status_code
|
|
7
|
+
self.response = response or {}
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AuthenticationError(UnboxAIError):
|
|
11
|
+
"""Raised when the API key is missing, invalid, or revoked (HTTP 401)."""
|
|
12
|
+
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class RateLimitError(UnboxAIError):
|
|
17
|
+
"""Raised when the user has sent too many requests (HTTP 429)."""
|
|
18
|
+
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class BadRequestError(UnboxAIError):
|
|
23
|
+
"""Raised when the request is invalid or missing required parameters (HTTP 400)."""
|
|
24
|
+
|
|
25
|
+
pass
|
|
File without changes
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import httpx
|
|
2
|
+
|
|
3
|
+
from behaviorgpt._exceptions import (
|
|
4
|
+
AuthenticationError,
|
|
5
|
+
BadRequestError,
|
|
6
|
+
RateLimitError,
|
|
7
|
+
UnboxAIError,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def handle_response(response: httpx.Response):
|
|
12
|
+
if response.status_code < 400:
|
|
13
|
+
if response.status_code == 204: # No Content
|
|
14
|
+
return None
|
|
15
|
+
return response.json()
|
|
16
|
+
|
|
17
|
+
# the API answers {"detail": ...}; a proxy in front of it may answer plain
|
|
18
|
+
# text or JSON of any shape
|
|
19
|
+
try:
|
|
20
|
+
body = response.json()
|
|
21
|
+
except ValueError:
|
|
22
|
+
body = None
|
|
23
|
+
if isinstance(body, dict) and "detail" in body:
|
|
24
|
+
error_data = body["detail"]
|
|
25
|
+
else:
|
|
26
|
+
error_data = response.text or "Unknown Error"
|
|
27
|
+
|
|
28
|
+
if response.status_code == 401:
|
|
29
|
+
raise AuthenticationError(
|
|
30
|
+
f"Authentication failed: {error_data}", status_code=401
|
|
31
|
+
)
|
|
32
|
+
elif response.status_code == 429:
|
|
33
|
+
raise RateLimitError(f"Rate limit exceeded: {error_data}", status_code=429)
|
|
34
|
+
elif response.status_code == 400:
|
|
35
|
+
raise BadRequestError(f"Bad request: {error_data}", status_code=400)
|
|
36
|
+
|
|
37
|
+
raise UnboxAIError(f"API Error: {error_data}", status_code=response.status_code)
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import time
|
|
2
|
+
import warnings
|
|
3
|
+
from datetime import UTC, datetime
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Callable, Optional
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
import pyarrow as pa
|
|
9
|
+
import pyarrow.parquet as pq
|
|
10
|
+
|
|
11
|
+
from behaviorgpt._exceptions import UnboxAIError
|
|
12
|
+
from behaviorgpt.resources._shared import handle_response
|
|
13
|
+
from behaviorgpt.types import (
|
|
14
|
+
EmbedJobDetails,
|
|
15
|
+
JobStatus,
|
|
16
|
+
SimilarItemsRequest,
|
|
17
|
+
UnboxAIResponse,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
# The three states the catalogs.status CHECK constraint allows, server-side.
|
|
21
|
+
PENDING_STATUS = "pending"
|
|
22
|
+
READY_STATUS = "ready"
|
|
23
|
+
FAILED_STATUS = "failed"
|
|
24
|
+
QUEUED_STATUS = "queued"
|
|
25
|
+
|
|
26
|
+
# A status read that fails with one of these says nothing about the job.
|
|
27
|
+
RETRYABLE_STATUS_CODES = frozenset({429, 500, 502, 503, 504})
|
|
28
|
+
|
|
29
|
+
# Arrow type per column, see docs/catalog-format.md. The server checks column
|
|
30
|
+
# names only; a wrong type fails or degrades the job later, so check it here.
|
|
31
|
+
CATALOG_SCHEMA = {
|
|
32
|
+
"id": pa.string(),
|
|
33
|
+
"name": pa.string(),
|
|
34
|
+
"brand": pa.string(),
|
|
35
|
+
"categories": pa.string(),
|
|
36
|
+
"image_url": pa.string(),
|
|
37
|
+
"event_type": pa.string(),
|
|
38
|
+
"group": pa.string(),
|
|
39
|
+
"sales_since": pa.list_(pa.int64()),
|
|
40
|
+
"timestamp": pa.int64(),
|
|
41
|
+
"frequency": pa.int64(),
|
|
42
|
+
"market": pa.string(),
|
|
43
|
+
"price": pa.string(),
|
|
44
|
+
"currency": pa.string(),
|
|
45
|
+
"search_keywords": pa.list_(pa.string()),
|
|
46
|
+
"keywords": pa.list_(pa.string()),
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def check_catalog_schema(path: Path) -> None:
|
|
51
|
+
"""Raise `UnboxAIError` if the parquet's columns do not match `CATALOG_SCHEMA`.
|
|
52
|
+
|
|
53
|
+
An entirely null column has Arrow type `null` and is accepted.
|
|
54
|
+
"""
|
|
55
|
+
try:
|
|
56
|
+
schema = pq.read_schema(path)
|
|
57
|
+
except (pa.ArrowException, OSError) as exc:
|
|
58
|
+
raise UnboxAIError(f"{path} is not a readable parquet file: {exc}") from exc
|
|
59
|
+
problems = []
|
|
60
|
+
for column, expected in CATALOG_SCHEMA.items():
|
|
61
|
+
if column not in schema.names:
|
|
62
|
+
problems.append(f"{column}: missing")
|
|
63
|
+
continue
|
|
64
|
+
actual = schema.field(column).type
|
|
65
|
+
if actual != expected and not pa.types.is_null(actual):
|
|
66
|
+
problems.append(f"{column}: expected {expected}, got {actual}")
|
|
67
|
+
if problems:
|
|
68
|
+
raise UnboxAIError(
|
|
69
|
+
f"{path} does not match the catalog format:\n " + "\n ".join(problems)
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def format_reference_time(reference_time: datetime | str) -> str:
|
|
74
|
+
"""Format `reference_time` for the upload; a `datetime` must be timezone-aware."""
|
|
75
|
+
if isinstance(reference_time, str):
|
|
76
|
+
return reference_time
|
|
77
|
+
if reference_time.tzinfo is None:
|
|
78
|
+
raise ValueError("reference_time must be timezone-aware")
|
|
79
|
+
return reference_time.astimezone(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class ProgressPrinter:
|
|
83
|
+
"""Default `on_progress` callback: print each new state once.
|
|
84
|
+
|
|
85
|
+
`wait_for_job` reads the status every few seconds; most reads repeat the
|
|
86
|
+
previous one, so printing every read would flood a notebook cell.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
def __init__(self) -> None:
|
|
90
|
+
self.last: Optional[str] = None
|
|
91
|
+
|
|
92
|
+
def __call__(self, status: JobStatus) -> None:
|
|
93
|
+
line = status.describe()
|
|
94
|
+
if line != self.last:
|
|
95
|
+
print(line)
|
|
96
|
+
self.last = line
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class Catalogs:
|
|
100
|
+
"""Upload a catalog parquet and embed it into the model's item space."""
|
|
101
|
+
|
|
102
|
+
def __init__(self, http_client: httpx.Client):
|
|
103
|
+
self._client = http_client
|
|
104
|
+
|
|
105
|
+
def embed(
|
|
106
|
+
self,
|
|
107
|
+
path: Path,
|
|
108
|
+
wait: bool = False,
|
|
109
|
+
interval: float = 5.0,
|
|
110
|
+
timeout: float = 600.0,
|
|
111
|
+
on_progress: Optional[Callable[[JobStatus], None]] = None,
|
|
112
|
+
startup_grace: float = 120.0,
|
|
113
|
+
reference_time: Optional[datetime | str] = None,
|
|
114
|
+
) -> EmbedJobDetails:
|
|
115
|
+
"""Upload the parquet at `path`.
|
|
116
|
+
|
|
117
|
+
The catalog id is minted server-side; `catalog_name` is the file's
|
|
118
|
+
stem and is for display only.
|
|
119
|
+
|
|
120
|
+
With `wait=True`, block until the job reaches a terminal state,
|
|
121
|
+
polling every `interval` seconds and giving up after `timeout`.
|
|
122
|
+
`on_progress` is called with each status read while waiting; by
|
|
123
|
+
default each new state is printed once (see `ProgressPrinter`).
|
|
124
|
+
"""
|
|
125
|
+
check_catalog_schema(path)
|
|
126
|
+
form = (
|
|
127
|
+
{}
|
|
128
|
+
if reference_time is None
|
|
129
|
+
else {"reference_time": format_reference_time(reference_time)}
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
with open(path, "rb") as f:
|
|
133
|
+
response = self._client.post(
|
|
134
|
+
"/embed-catalog",
|
|
135
|
+
files={"file": (path.name, f, "application/octet-stream")},
|
|
136
|
+
data=form,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
data = handle_response(response)
|
|
140
|
+
|
|
141
|
+
job = EmbedJobDetails(**data)
|
|
142
|
+
|
|
143
|
+
if wait:
|
|
144
|
+
self.wait_for_job(
|
|
145
|
+
job.job_id,
|
|
146
|
+
interval=interval,
|
|
147
|
+
timeout=timeout,
|
|
148
|
+
on_progress=on_progress,
|
|
149
|
+
startup_grace=startup_grace,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
return job
|
|
153
|
+
|
|
154
|
+
def get_job_status(self, job_id: str) -> JobStatus:
|
|
155
|
+
"""Read the job state once, without blocking."""
|
|
156
|
+
response = self._client.get(f"/embed-catalog/{job_id}")
|
|
157
|
+
data = handle_response(response)
|
|
158
|
+
return JobStatus(**data)
|
|
159
|
+
|
|
160
|
+
def wait_for_job(
|
|
161
|
+
self,
|
|
162
|
+
job_id: str,
|
|
163
|
+
interval: float = 5.0,
|
|
164
|
+
timeout: float = 600.0,
|
|
165
|
+
on_progress: Optional[Callable[[JobStatus], None]] = None,
|
|
166
|
+
startup_grace: float = 120.0,
|
|
167
|
+
failed_grace: float = 180.0,
|
|
168
|
+
) -> JobStatus:
|
|
169
|
+
"""Block until the job succeeds; raise on failure or timeout.
|
|
170
|
+
|
|
171
|
+
`on_progress` is called with every status read, terminal one included.
|
|
172
|
+
It defaults to a `ProgressPrinter`, which prints each new state once;
|
|
173
|
+
pass your own callable to render progress differently, or
|
|
174
|
+
`lambda _: None` for silence.
|
|
175
|
+
|
|
176
|
+
A job reads as 404 until a worker picks it up and writes its first
|
|
177
|
+
state, which can be seconds after the upload returns (or longer if the
|
|
178
|
+
GPU worker is busy). Within `startup_grace` seconds that 404 is
|
|
179
|
+
reported as status `queued` and polling continues; after it, the 404
|
|
180
|
+
is raised as it stands.
|
|
181
|
+
|
|
182
|
+
A job that fails for good says why in `error`. A `failed` status
|
|
183
|
+
without an error can be temporary, while the job is retried, so it is
|
|
184
|
+
only final once it has lasted `failed_grace` seconds.
|
|
185
|
+
|
|
186
|
+
A status read that fails with a 429, a 5xx or a network error says
|
|
187
|
+
nothing about the job, so polling continues until `timeout`.
|
|
188
|
+
"""
|
|
189
|
+
if on_progress is None:
|
|
190
|
+
on_progress = ProgressPrinter()
|
|
191
|
+
|
|
192
|
+
started = time.monotonic()
|
|
193
|
+
deadline = started + timeout
|
|
194
|
+
seen = False
|
|
195
|
+
last_state = QUEUED_STATUS
|
|
196
|
+
failed_since: Optional[float] = None
|
|
197
|
+
read_error: Optional[Exception] = None
|
|
198
|
+
|
|
199
|
+
while True:
|
|
200
|
+
status: Optional[JobStatus] = None
|
|
201
|
+
try:
|
|
202
|
+
status = self.get_job_status(job_id)
|
|
203
|
+
except httpx.TransportError as exc:
|
|
204
|
+
read_error = exc
|
|
205
|
+
except UnboxAIError as exc:
|
|
206
|
+
not_started = (
|
|
207
|
+
exc.status_code == 404
|
|
208
|
+
and not seen
|
|
209
|
+
and time.monotonic() - started < startup_grace
|
|
210
|
+
)
|
|
211
|
+
if not_started:
|
|
212
|
+
status = JobStatus(job_id=job_id, status=QUEUED_STATUS)
|
|
213
|
+
elif exc.status_code in RETRYABLE_STATUS_CODES:
|
|
214
|
+
read_error = exc
|
|
215
|
+
else:
|
|
216
|
+
raise
|
|
217
|
+
else:
|
|
218
|
+
seen = True
|
|
219
|
+
read_error = None
|
|
220
|
+
|
|
221
|
+
if status is not None:
|
|
222
|
+
on_progress(status)
|
|
223
|
+
last_state = status.status.lower()
|
|
224
|
+
|
|
225
|
+
if last_state == READY_STATUS:
|
|
226
|
+
return status
|
|
227
|
+
|
|
228
|
+
if last_state == FAILED_STATUS:
|
|
229
|
+
now = time.monotonic()
|
|
230
|
+
failed_since = now if failed_since is None else failed_since
|
|
231
|
+
if status.error or now - failed_since >= failed_grace:
|
|
232
|
+
reason = f": {status.error}" if status.error else ""
|
|
233
|
+
raise UnboxAIError(
|
|
234
|
+
f"Embed job {job_id} failed{reason}",
|
|
235
|
+
response=status.model_dump(),
|
|
236
|
+
)
|
|
237
|
+
else:
|
|
238
|
+
failed_since = None
|
|
239
|
+
|
|
240
|
+
if time.monotonic() + interval > deadline:
|
|
241
|
+
message = f"Embed job {job_id} still '{last_state}' after {timeout:g}s"
|
|
242
|
+
if read_error:
|
|
243
|
+
message += f"; last status read failed: {read_error}"
|
|
244
|
+
raise UnboxAIError(message)
|
|
245
|
+
|
|
246
|
+
time.sleep(interval)
|
|
247
|
+
|
|
248
|
+
def get_similar_items(
|
|
249
|
+
self,
|
|
250
|
+
item_id: str,
|
|
251
|
+
limit: int = 10,
|
|
252
|
+
offset: int = 0,
|
|
253
|
+
*,
|
|
254
|
+
catalog_id: str,
|
|
255
|
+
filters: Optional[dict] = None,
|
|
256
|
+
headers: Optional[dict] = None,
|
|
257
|
+
) -> UnboxAIResponse:
|
|
258
|
+
req = SimilarItemsRequest(
|
|
259
|
+
item_id=item_id,
|
|
260
|
+
store_id=catalog_id,
|
|
261
|
+
limit=limit,
|
|
262
|
+
offset=offset,
|
|
263
|
+
filters=filters or {},
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
response = self._client.post(
|
|
267
|
+
"/vector/similar_products",
|
|
268
|
+
json=req.model_dump(mode="json"),
|
|
269
|
+
headers=headers,
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
data = handle_response(response)
|
|
273
|
+
|
|
274
|
+
return UnboxAIResponse(**data)
|
|
275
|
+
|
|
276
|
+
def get_similar_products(
|
|
277
|
+
self, product_id: str, *args: Any, **kwargs: Any
|
|
278
|
+
) -> UnboxAIResponse:
|
|
279
|
+
warnings.warn(
|
|
280
|
+
"`get_similar_products` is deprecated, use `get_similar_items`",
|
|
281
|
+
DeprecationWarning,
|
|
282
|
+
stacklevel=2,
|
|
283
|
+
)
|
|
284
|
+
return self.get_similar_items(product_id, *args, **kwargs)
|
|
285
|
+
|
|
286
|
+
def get_umap(self, *, catalog_id: str):
|
|
287
|
+
response = self._client.get(
|
|
288
|
+
f"/{catalog_id}/umap",
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
if response.is_error:
|
|
292
|
+
handle_response(response)
|
|
293
|
+
return response.text
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from typing import Optional, Union
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
|
|
5
|
+
from behaviorgpt.resources._shared import handle_response
|
|
6
|
+
from behaviorgpt.types import UnboxAIRequest, UnboxAIResponse
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Recommender:
|
|
10
|
+
def __init__(self, http_client: httpx.Client):
|
|
11
|
+
self._client = http_client
|
|
12
|
+
|
|
13
|
+
def create(
|
|
14
|
+
self, payload: Union[UnboxAIRequest, dict], *, headers: Optional[dict] = None
|
|
15
|
+
) -> UnboxAIResponse:
|
|
16
|
+
if isinstance(payload, dict):
|
|
17
|
+
payload = UnboxAIRequest(**payload)
|
|
18
|
+
|
|
19
|
+
response = self._client.post(
|
|
20
|
+
"/vector/recommendations",
|
|
21
|
+
json=payload.model_dump(mode="json", exclude_none=True),
|
|
22
|
+
headers=headers,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
data = handle_response(response)
|
|
26
|
+
market_val = getattr(payload.domains, "market", None)
|
|
27
|
+
|
|
28
|
+
return UnboxAIResponse(**data, market=market_val)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
from typing import List, Optional
|
|
3
|
+
|
|
4
|
+
from behaviorgpt.types import (
|
|
5
|
+
Domains,
|
|
6
|
+
SessionEvent,
|
|
7
|
+
UnboxAIRequest,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class UnboxAIRequester:
|
|
12
|
+
def __init__(self, domains: Domains, timezone: str = "UTC"):
|
|
13
|
+
self.domains = domains
|
|
14
|
+
self.timezone = timezone
|
|
15
|
+
|
|
16
|
+
def make(
|
|
17
|
+
self,
|
|
18
|
+
catalog_id: str,
|
|
19
|
+
query: str | None = None,
|
|
20
|
+
history: List[SessionEvent] | None = None,
|
|
21
|
+
domains: Optional[Domains] = None,
|
|
22
|
+
limit: int = 10,
|
|
23
|
+
offset: int = 0,
|
|
24
|
+
filters: Optional[dict] = None,
|
|
25
|
+
register_event: bool = False,
|
|
26
|
+
timestamp: Optional[datetime] = None,
|
|
27
|
+
num_candidates: Optional[int] = None,
|
|
28
|
+
) -> UnboxAIRequest:
|
|
29
|
+
return UnboxAIRequest(
|
|
30
|
+
query=query,
|
|
31
|
+
history=history or [],
|
|
32
|
+
store_id=catalog_id,
|
|
33
|
+
domains=domains or self.domains,
|
|
34
|
+
timezone=self.timezone,
|
|
35
|
+
limit=limit,
|
|
36
|
+
offset=offset,
|
|
37
|
+
filters=filters or {},
|
|
38
|
+
register_event=register_event,
|
|
39
|
+
timestamp=timestamp,
|
|
40
|
+
num_candidates=num_candidates,
|
|
41
|
+
)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from typing import Union
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
|
|
5
|
+
from behaviorgpt.resources._shared import handle_response
|
|
6
|
+
from behaviorgpt.types import UnboxAIRequest, UnboxAIResponse
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Searcher:
|
|
10
|
+
def __init__(self, http_client: httpx.Client):
|
|
11
|
+
self._client = http_client
|
|
12
|
+
|
|
13
|
+
def create(
|
|
14
|
+
self, payload: Union[UnboxAIRequest, dict], *, headers: dict | None = None
|
|
15
|
+
) -> UnboxAIResponse:
|
|
16
|
+
if isinstance(payload, dict):
|
|
17
|
+
payload = UnboxAIRequest(**payload)
|
|
18
|
+
|
|
19
|
+
response = self._client.post(
|
|
20
|
+
"/vector/search",
|
|
21
|
+
json=payload.model_dump(mode="json", exclude_none=True),
|
|
22
|
+
headers=headers,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
data = handle_response(response)
|
|
26
|
+
market_val = getattr(payload.domains, "market", None)
|
|
27
|
+
|
|
28
|
+
return UnboxAIResponse(**data, market=market_val)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import warnings
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from behaviorgpt.types import http
|
|
5
|
+
from behaviorgpt.types.domains import Domains
|
|
6
|
+
from behaviorgpt.types.events import (
|
|
7
|
+
AddToCart,
|
|
8
|
+
CartItem,
|
|
9
|
+
Order,
|
|
10
|
+
RemoveFromCart,
|
|
11
|
+
Search,
|
|
12
|
+
SessionEvent,
|
|
13
|
+
UserEvent,
|
|
14
|
+
UserHistoryInput,
|
|
15
|
+
View,
|
|
16
|
+
)
|
|
17
|
+
from behaviorgpt.types.http import (
|
|
18
|
+
EmbedJobDetails,
|
|
19
|
+
Item,
|
|
20
|
+
ItemsPage,
|
|
21
|
+
ItemsRejected,
|
|
22
|
+
JobStatus,
|
|
23
|
+
RejectedItem,
|
|
24
|
+
SimilarItemsRequest,
|
|
25
|
+
UnboxAIRequest,
|
|
26
|
+
UnboxAIResponse,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"UserEvent",
|
|
31
|
+
"SessionEvent",
|
|
32
|
+
"Search",
|
|
33
|
+
"AddToCart",
|
|
34
|
+
"View",
|
|
35
|
+
"RemoveFromCart",
|
|
36
|
+
"Order",
|
|
37
|
+
"UnboxAIResponse",
|
|
38
|
+
"UnboxAIRequest",
|
|
39
|
+
"Domains",
|
|
40
|
+
"UserHistoryInput",
|
|
41
|
+
"SimilarItemsRequest",
|
|
42
|
+
"EmbedJobDetails",
|
|
43
|
+
"JobStatus",
|
|
44
|
+
"ItemsRejected",
|
|
45
|
+
"RejectedItem",
|
|
46
|
+
"Item",
|
|
47
|
+
"ItemsPage",
|
|
48
|
+
"CartItem",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def __getattr__(name: str) -> Any:
|
|
53
|
+
if name in http.DEPRECATED_NAMES:
|
|
54
|
+
new = http.DEPRECATED_NAMES[name]
|
|
55
|
+
warnings.warn(
|
|
56
|
+
f"`{name}` is deprecated, use `{new}`", DeprecationWarning, stacklevel=2
|
|
57
|
+
)
|
|
58
|
+
return getattr(http, new)
|
|
59
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import warnings
|
|
2
|
+
from datetime import UTC, datetime
|
|
3
|
+
from typing import List, Literal, Optional, Sequence, Tuple, Union
|
|
4
|
+
|
|
5
|
+
from pydantic import AliasChoices, BaseModel, ConfigDict, Field
|
|
6
|
+
|
|
7
|
+
from behaviorgpt.types.domains import Domains
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class _ItemEvent(BaseModel):
|
|
11
|
+
# the API calls the item "product"
|
|
12
|
+
model_config = ConfigDict(serialize_by_alias=True)
|
|
13
|
+
|
|
14
|
+
item: str = Field(
|
|
15
|
+
validation_alias=AliasChoices("item", "product"),
|
|
16
|
+
serialization_alias="product",
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
def __init__(self, item: Optional[str] = None, **data):
|
|
20
|
+
if "product" in data:
|
|
21
|
+
warnings.warn(
|
|
22
|
+
"`product=` is deprecated, use `item=`",
|
|
23
|
+
DeprecationWarning,
|
|
24
|
+
stacklevel=2,
|
|
25
|
+
)
|
|
26
|
+
product = data.pop("product")
|
|
27
|
+
item = product if item is None else item
|
|
28
|
+
if item is not None:
|
|
29
|
+
data["item"] = item
|
|
30
|
+
super().__init__(**data)
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def product(self) -> str:
|
|
34
|
+
warnings.warn(
|
|
35
|
+
"`.product` is deprecated, use `.item`", DeprecationWarning, stacklevel=2
|
|
36
|
+
)
|
|
37
|
+
return self.item
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class CartItem(_ItemEvent):
|
|
41
|
+
quantity: int
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self, item: Optional[str] = None, quantity: Optional[int] = None, **data
|
|
45
|
+
):
|
|
46
|
+
if quantity is not None:
|
|
47
|
+
data["quantity"] = quantity
|
|
48
|
+
super().__init__(item, **data)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class Search(BaseModel):
|
|
52
|
+
type: Literal["search"] = "search"
|
|
53
|
+
query: str
|
|
54
|
+
|
|
55
|
+
def __init__(self, query: Optional[str] = None, **data):
|
|
56
|
+
if query is not None:
|
|
57
|
+
data["query"] = query
|
|
58
|
+
super().__init__(**data)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class View(_ItemEvent):
|
|
62
|
+
type: Literal["viewItem"] = "viewItem"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class AddToCart(_ItemEvent):
|
|
66
|
+
type: Literal["addToCart"] = "addToCart"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class RemoveFromCart(_ItemEvent):
|
|
70
|
+
type: Literal["removeFromCart"] = "removeFromCart"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class Order(BaseModel):
|
|
74
|
+
type: Literal["order"] = "order"
|
|
75
|
+
|
|
76
|
+
cart_id: Optional[str] = None
|
|
77
|
+
items: Optional[List[CartItem]] = None
|
|
78
|
+
|
|
79
|
+
def __init__(
|
|
80
|
+
self,
|
|
81
|
+
cart_id: Optional[str] = None,
|
|
82
|
+
items: Optional[List[Union[CartItem, Tuple[str, int]]]] = None,
|
|
83
|
+
**data,
|
|
84
|
+
):
|
|
85
|
+
if cart_id is not None:
|
|
86
|
+
data["cart_id"] = cart_id
|
|
87
|
+
|
|
88
|
+
if items is not None:
|
|
89
|
+
parsed_items = []
|
|
90
|
+
for entry in items:
|
|
91
|
+
if isinstance(entry, tuple):
|
|
92
|
+
item_id, quantity = entry
|
|
93
|
+
parsed_items.append(CartItem(item_id, quantity))
|
|
94
|
+
else:
|
|
95
|
+
parsed_items.append(entry)
|
|
96
|
+
|
|
97
|
+
data["items"] = parsed_items
|
|
98
|
+
|
|
99
|
+
super().__init__(**data)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
UserEvent = Union[Search, AddToCart, View, RemoveFromCart, Order]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class SessionEvent(BaseModel):
|
|
106
|
+
store_id: str
|
|
107
|
+
domains: Domains
|
|
108
|
+
timezone: str
|
|
109
|
+
timestamp: datetime = Field(default_factory=lambda: datetime.now(tz=UTC))
|
|
110
|
+
event: UserEvent
|
|
111
|
+
session_id: str
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
UserHistoryInput = Union[Sequence[UserEvent], Sequence[Sequence[UserEvent]]]
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import warnings
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from typing import TYPE_CHECKING, Any, List, Optional
|
|
4
|
+
|
|
5
|
+
from pydantic import (
|
|
6
|
+
AliasChoices,
|
|
7
|
+
BaseModel,
|
|
8
|
+
ConfigDict,
|
|
9
|
+
Field,
|
|
10
|
+
field_validator,
|
|
11
|
+
model_validator,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
import pandas as pd
|
|
16
|
+
|
|
17
|
+
from behaviorgpt.types.domains import Domains
|
|
18
|
+
from behaviorgpt.types.events import SessionEvent
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Item(BaseModel):
|
|
22
|
+
id: str
|
|
23
|
+
data: dict
|
|
24
|
+
score: float
|
|
25
|
+
|
|
26
|
+
def extract_price(self, market: str | None = None) -> Optional[float]:
|
|
27
|
+
if "price" in self.data:
|
|
28
|
+
return self.data["price"]
|
|
29
|
+
|
|
30
|
+
markets = self.data.get("markets", {})
|
|
31
|
+
if isinstance(markets, dict):
|
|
32
|
+
market_data = markets.get(market)
|
|
33
|
+
if isinstance(market_data, dict) and "price" in market_data:
|
|
34
|
+
return market_data["price"]
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ItemsPage(BaseModel):
|
|
39
|
+
items: List[Item]
|
|
40
|
+
offset: int
|
|
41
|
+
limit: int
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class UnboxAIRequest(BaseModel):
|
|
45
|
+
query: Optional[str] = Field(default=None)
|
|
46
|
+
|
|
47
|
+
history: List[SessionEvent] = Field(default_factory=list)
|
|
48
|
+
store_id: str
|
|
49
|
+
domains: Domains
|
|
50
|
+
timezone: str = "UTC"
|
|
51
|
+
limit: int = Field(default=10, ge=1, le=100)
|
|
52
|
+
offset: int = Field(default=0, ge=0)
|
|
53
|
+
filters: dict = Field(default_factory=dict)
|
|
54
|
+
register_event: bool = True
|
|
55
|
+
# The moment the prediction is made; the server uses the current time if unset.
|
|
56
|
+
timestamp: Optional[datetime] = None
|
|
57
|
+
# How many nearest-neighbour candidates the server explores.
|
|
58
|
+
num_candidates: Optional[int] = Field(default=None, gt=0, le=5_000)
|
|
59
|
+
|
|
60
|
+
@field_validator("timestamp")
|
|
61
|
+
@classmethod
|
|
62
|
+
def require_timezone(cls, timestamp: Optional[datetime]) -> Optional[datetime]:
|
|
63
|
+
if timestamp is not None and timestamp.tzinfo is None:
|
|
64
|
+
raise ValueError("timestamp must be timezone-aware")
|
|
65
|
+
return timestamp
|
|
66
|
+
|
|
67
|
+
@model_validator(mode="after")
|
|
68
|
+
def validate_pagination(self) -> "UnboxAIRequest":
|
|
69
|
+
if self.offset + self.limit > 10000:
|
|
70
|
+
raise ValueError("Offset + limit cannot exceed 10,000")
|
|
71
|
+
return self
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class UnboxAIResponse(BaseModel):
|
|
75
|
+
items: List[Item]
|
|
76
|
+
offset: int
|
|
77
|
+
limit: int
|
|
78
|
+
|
|
79
|
+
market: Optional[str] = Field(default=None, exclude=True)
|
|
80
|
+
|
|
81
|
+
@model_validator(mode="before")
|
|
82
|
+
@classmethod
|
|
83
|
+
def _flatten_products(cls, data: Any) -> Any:
|
|
84
|
+
# the API nests the page under "products"
|
|
85
|
+
if isinstance(data, dict) and "products" in data:
|
|
86
|
+
data = dict(data)
|
|
87
|
+
products = data.pop("products")
|
|
88
|
+
if isinstance(products, ItemsPage):
|
|
89
|
+
products = products.model_dump()
|
|
90
|
+
data = {**products, **data}
|
|
91
|
+
return data
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def products(self) -> ItemsPage:
|
|
95
|
+
warnings.warn(
|
|
96
|
+
"`response.products` is deprecated; use `response.items`, "
|
|
97
|
+
"`response.offset` and `response.limit`",
|
|
98
|
+
DeprecationWarning,
|
|
99
|
+
stacklevel=2,
|
|
100
|
+
)
|
|
101
|
+
return ItemsPage(items=self.items, offset=self.offset, limit=self.limit)
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def names(self) -> List[str]:
|
|
105
|
+
return [item.data.get("name", "Unknown") for item in self.items]
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def mean_price(self) -> Optional[float]:
|
|
109
|
+
prices = []
|
|
110
|
+
for item in self.items:
|
|
111
|
+
price = item.extract_price(self.market)
|
|
112
|
+
if price is not None:
|
|
113
|
+
try:
|
|
114
|
+
prices.append(float(price))
|
|
115
|
+
except (ValueError, TypeError):
|
|
116
|
+
pass
|
|
117
|
+
|
|
118
|
+
return sum(prices) / len(prices) if prices else None
|
|
119
|
+
|
|
120
|
+
def to_pandas(self) -> "pd.DataFrame":
|
|
121
|
+
# pandas is optional: only this method needs it
|
|
122
|
+
try:
|
|
123
|
+
import pandas as pd
|
|
124
|
+
except ImportError:
|
|
125
|
+
raise ImportError(
|
|
126
|
+
"to_pandas() needs pandas; install it with `pip install pandas`"
|
|
127
|
+
) from None
|
|
128
|
+
|
|
129
|
+
items_data = []
|
|
130
|
+
for item in self.items:
|
|
131
|
+
flat_item = {"id": item.id, "score": item.score, **item.data}
|
|
132
|
+
flat_item["price"] = item.extract_price(self.market)
|
|
133
|
+
items_data.append(flat_item)
|
|
134
|
+
|
|
135
|
+
return pd.DataFrame(items_data)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class EmbedJobDetails(BaseModel):
|
|
139
|
+
job_id: str
|
|
140
|
+
catalog_id: str
|
|
141
|
+
# display label derived from the uploaded file name, server-side
|
|
142
|
+
catalog_name: str
|
|
143
|
+
uri: str
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class SimilarItemsRequest(BaseModel):
|
|
147
|
+
model_config = ConfigDict(serialize_by_alias=True)
|
|
148
|
+
|
|
149
|
+
item_id: str = Field(
|
|
150
|
+
validation_alias=AliasChoices("item_id", "product_id"),
|
|
151
|
+
serialization_alias="product_id",
|
|
152
|
+
)
|
|
153
|
+
store_id: str
|
|
154
|
+
limit: int = Field(default=10, ge=1, le=100)
|
|
155
|
+
offset: int = Field(default=0, ge=0)
|
|
156
|
+
filters: dict = Field(default_factory=dict)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class RejectedItem(BaseModel):
|
|
160
|
+
id: str
|
|
161
|
+
# why the index refused it, e.g. "failed to parse field [stock] of type
|
|
162
|
+
# [long]"
|
|
163
|
+
reason: str
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class ItemsRejected(BaseModel):
|
|
167
|
+
"""Items left out of a catalog that is otherwise ready: the index refused
|
|
168
|
+
them for their content. Fix those rows and re-upload to add them."""
|
|
169
|
+
|
|
170
|
+
count: int
|
|
171
|
+
total: int
|
|
172
|
+
# the first of them (at most 100); "products" on the wire
|
|
173
|
+
items: List[RejectedItem] = Field(
|
|
174
|
+
default_factory=list, validation_alias=AliasChoices("items", "products")
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
def describe(self) -> str:
|
|
178
|
+
ids = ", ".join(repr(i.id) for i in self.items[:3])
|
|
179
|
+
more = f" and {self.count - 3} more" if self.count > 3 else ""
|
|
180
|
+
return f"{self.count} of {self.total} items rejected: {ids}{more}"
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class JobStatus(BaseModel):
|
|
184
|
+
job_id: str
|
|
185
|
+
status: str
|
|
186
|
+
stage: Optional[str] = None
|
|
187
|
+
stage_state: Optional[str] = None
|
|
188
|
+
phase: Optional[str] = None
|
|
189
|
+
done: Optional[int] = None
|
|
190
|
+
total: Optional[int] = None
|
|
191
|
+
# why the job failed, e.g. "catalog parquet is empty"; only set when
|
|
192
|
+
# status is "failed", and not for every failure
|
|
193
|
+
error: Optional[str] = None
|
|
194
|
+
# items left out of a ready catalog; the job still succeeded.
|
|
195
|
+
# "products_rejected" on the wire
|
|
196
|
+
items_rejected: Optional[ItemsRejected] = Field(
|
|
197
|
+
default=None,
|
|
198
|
+
validation_alias=AliasChoices("items_rejected", "products_rejected"),
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
def describe(self) -> str:
|
|
202
|
+
"""One line for progress output: 'embed fetching 5000/20000',
|
|
203
|
+
'embed failed: <reason>', or 'ingest done (2 of 20000 items
|
|
204
|
+
rejected: ...)'."""
|
|
205
|
+
if self.stage is None:
|
|
206
|
+
return self.status
|
|
207
|
+
step = self.phase or self.stage_state or ""
|
|
208
|
+
if self.done is not None and self.total is not None:
|
|
209
|
+
step = f"{step} {self.done}/{self.total}"
|
|
210
|
+
line = f"{self.stage} {step}".strip()
|
|
211
|
+
if self.error:
|
|
212
|
+
line = f"{line}: {self.error}"
|
|
213
|
+
if self.items_rejected:
|
|
214
|
+
line = f"{line} ({self.items_rejected.describe()})"
|
|
215
|
+
return line
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
DEPRECATED_NAMES = {
|
|
219
|
+
"ProductResponse": "ItemsPage",
|
|
220
|
+
"SimilarProductsRequest": "SimilarItemsRequest",
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def __getattr__(name: str) -> Any:
|
|
225
|
+
if name in DEPRECATED_NAMES:
|
|
226
|
+
new = DEPRECATED_NAMES[name]
|
|
227
|
+
warnings.warn(
|
|
228
|
+
f"`{name}` is deprecated, use `{new}`", DeprecationWarning, stacklevel=2
|
|
229
|
+
)
|
|
230
|
+
return globals()[new]
|
|
231
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: behaviorgpt
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client for BehaviorGPT, UnboxAI's Large Behavioral Model for search, recommendations and personalization
|
|
5
|
+
Keywords: recommendations,personalization,search,behavioral model,unboxai
|
|
6
|
+
Author: Unbox AI
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Dist: httpx>=0.28.1
|
|
10
|
+
Requires-Dist: pyarrow>=25.0.1
|
|
11
|
+
Requires-Dist: pydantic>=2.13.4
|
|
12
|
+
Requires-Python: >=3.11
|
|
13
|
+
Project-URL: Homepage, https://unboxai.com/behaviorgpt
|
|
14
|
+
Project-URL: Repository, https://github.com/Unbox-AI/behaviorgpt
|
|
15
|
+
Project-URL: Demo, https://behaviorgpt.unboxai.com/
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# BehaviorGPT
|
|
19
|
+
|
|
20
|
+
**The foundation model for behavior.**
|
|
21
|
+
|
|
22
|
+
**BehaviorGPT** is UnboxAI's Large Behavioral Model. It is trained on long sequences of things people actually did (viewed this, added that, bought the other) and predicts what comes next, so search, recommendations and personalization are the same call with different histories. BehaviorGPT-v4 is a single 12.5B-parameter model pretrained on 150 billion user actions across retail, engagement and payments, and it ranks catalogs it has never seen with no training.
|
|
23
|
+
|
|
24
|
+
[Website](https://unboxai.com/behaviorgpt) · [Live demo](https://behaviorgpt.unboxai.com/) · [Research](https://research.unboxai.com) · [Notebook](https://github.com/Unbox-AI/behaviorgpt/blob/main/notebooks/showcase.ipynb)
|
|
25
|
+
|
|
26
|
+
This repository holds the Python client for the UnboxAI API and a notebook that walks through the model step by step.
|
|
27
|
+
|
|
28
|
+
## Requirements
|
|
29
|
+
|
|
30
|
+
- An UnboxAI API key. Create & copy the key at [unboxai.com/behaviorgpt](https://unboxai.com/behaviorgpt); the key also arrives by email.
|
|
31
|
+
- Python 3.11 or newer.
|
|
32
|
+
|
|
33
|
+
## Quick start
|
|
34
|
+
|
|
35
|
+
```sh
|
|
36
|
+
pip install behaviorgpt
|
|
37
|
+
export UNBOXAI_API_KEY=... # or pass api_key= to UnboxAIClient
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
from behaviorgpt import Search, UnboxAIClient, View
|
|
42
|
+
|
|
43
|
+
client = UnboxAIClient(market="us")
|
|
44
|
+
|
|
45
|
+
# a history of one search
|
|
46
|
+
res = client.complete([Search("running shoes")], limit=5)
|
|
47
|
+
print(res.names)
|
|
48
|
+
|
|
49
|
+
# the same call, personalized: a viewed item (Nike pants), then a query
|
|
50
|
+
res = client.complete([View("paB08NYK61PJ"), Search("shoes")], limit=5)
|
|
51
|
+
print(res.names)
|
|
52
|
+
|
|
53
|
+
# items close to one another in the model's space
|
|
54
|
+
print(client.similar_items(res.items[0].id).names)
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
`complete` takes the history and returns the items most likely to come next. Each result in `res.items` has an `id`, a `score` and the catalog row in `data`; `res.to_pandas()` gives a DataFrame if pandas is installed.
|
|
58
|
+
|
|
59
|
+
## How it works
|
|
60
|
+
|
|
61
|
+
- **One call, many features.** Every request asks the same question: given what this person did, which items are they most likely to act on next? The history picks the feature. End it with a `Search` for search results, with a `View` for "more like this", with an `Order` for what is bought next; leave it empty for a cold start.
|
|
62
|
+
- **The whole sequence counts.** The model reads the history in order, so the same query means different things after different actions: "gloves" after "golf clubs" returns golf gloves. `market` on the client says where the user is, which helps most when the history is short.
|
|
63
|
+
- **Items are read from their content, not their ids.** Each item is embedded from its name, brand, categories, image and price. That is why the model can rank a catalog it has never seen as soon as it is uploaded, with no interaction data and no training, and why those columns decide how well it does ([catalog format](https://github.com/Unbox-AI/behaviorgpt/blob/main/docs/catalog-format.md)).
|
|
64
|
+
- **One pass per request.** Item vectors are computed once when the catalog is embedded, so ranking the whole catalog costs one forward pass over the history rather than one per item: the model itself takes under a millisecond per query, and most of an API call's time is the network.
|
|
65
|
+
|
|
66
|
+
## The notebook
|
|
67
|
+
|
|
68
|
+
[`notebooks/showcase.ipynb`](https://github.com/Unbox-AI/behaviorgpt/blob/main/notebooks/showcase.ipynb) covers the model in eight steps: setup, a first query, one call for many features, how context changes the answer, then embedding your own catalog, inspecting the embedding space, querying it, and trying it in the demo.
|
|
69
|
+
|
|
70
|
+
Needs [uv](https://github.com/astral-sh/uv). Clone, install, add your key, register the kernel, start JupyterLab:
|
|
71
|
+
|
|
72
|
+
```sh
|
|
73
|
+
git clone https://github.com/Unbox-AI/behaviorgpt.git
|
|
74
|
+
cd behaviorgpt
|
|
75
|
+
uv sync
|
|
76
|
+
cp .env.example .env # paste your key as UNBOXAI_API_KEY
|
|
77
|
+
uv run ipython kernel install --user --env VIRTUAL_ENV $(pwd)/.venv --name=behaviorgpt-env
|
|
78
|
+
uv run jupyter lab
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
JupyterLab opens in your browser. Open the notebook and pick the `behaviorgpt-env` kernel.
|
|
82
|
+
|
|
83
|
+
## Your own catalog
|
|
84
|
+
|
|
85
|
+
The model can only return items it knows about. Out of the box the client uses `retail_catalog`, a sample retail catalog. To use the model on your own data, upload your catalog as a parquet file. A catalog doesn't have to be products for sale: artworks, articles, listings or anything else people browse and pick from work the same way.
|
|
86
|
+
|
|
87
|
+
- The required columns and types are described in [`docs/catalog-format.md`](https://github.com/Unbox-AI/behaviorgpt/blob/main/docs/catalog-format.md).
|
|
88
|
+
- A catalog can hold at most 20 000 items.
|
|
89
|
+
- Each API key can hold 2 catalogs. The file name is the catalog's name: uploading a file with the same name again refreshes that catalog and keeps its `catalog_id`, and a new name takes one of the 2 slots. A failed upload does not use up a slot. Once both slots are in use, an upload under a new name is refused; to remove a catalog or raise the limit, contact alexander@unboxai.com.
|
|
90
|
+
- [`examples/sample_catalog.csv`](https://github.com/Unbox-AI/behaviorgpt/blob/main/examples/sample_catalog.csv) is an example only, showing what each column should contain. Your own file will look different depending on your catalog, and it has to be converted to the parquet structure described in the format doc before upload.
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
job = client.embed("my_catalog.parquet", wait=True, timeout=1800.0)
|
|
94
|
+
client.complete([Search("...")], catalog_id=job.catalog_id)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
If mapping your data to the format is hard, open a coding agent in this directory and ask it to embed your catalog. The repo ships a skill at [`.claude/skills/embed-catalog/SKILL.md`](https://github.com/Unbox-AI/behaviorgpt/blob/main/.claude/skills/embed-catalog/SKILL.md) that walks the agent through mapping your columns, building and validating the parquet, checking image URLs, and uploading. Claude Code picks it up automatically; for other agents, point them at that file.
|
|
98
|
+
|
|
99
|
+
### Events
|
|
100
|
+
|
|
101
|
+
A history is a list of `Search`, `View`, `AddToCart`, `RemoveFromCart` and `Order` events. The names come from a store because that is what the API accepts, but they work for any catalog: map your users' actions by meaning. Curate my wall, below, sends "look closer" as `View`, "hang it on the wall" as `AddToCart` and "take it down" as `RemoveFromCart`.
|
|
102
|
+
|
|
103
|
+
## Demo and examples
|
|
104
|
+
|
|
105
|
+
Two apps built on this SDK, one on a store's catalog and one on a museum's:
|
|
106
|
+
|
|
107
|
+
<table>
|
|
108
|
+
<tr>
|
|
109
|
+
<td width="50%"><a href="https://behaviorgpt.unboxai.com/"><img src="https://raw.githubusercontent.com/Unbox-AI/behaviorgpt/main/docs/images/storefront.png" alt="The storefront demo: after a search for golf clubs, a search for gloves returns golf gloves"></a></td>
|
|
110
|
+
<td width="50%"><a href="https://github.com/Unbox-AI/aic-artworks"><img src="https://raw.githubusercontent.com/Unbox-AI/behaviorgpt/main/docs/images/curate-my-wall.png" alt="Curate my wall: three Hokusai prints hung on the wall, and the fashion they lead to: printed scarves, a tiger-print tee and a kimono"></a></td>
|
|
111
|
+
</tr>
|
|
112
|
+
<tr>
|
|
113
|
+
<td><b>Storefront</b> (<a href="https://behaviorgpt.unboxai.com/">live demo</a>). "gloves" after "golf clubs" returns golf gloves; the panel shows the history the model read.</td>
|
|
114
|
+
<td><b>Curate my wall</b> (<a href="https://aic-artworks.streamlit.app/">live app</a>, <a href="https://github.com/Unbox-AI/aic-artworks">source</a>). Hang three Hokusai prints and the same clicks pick your clothes: scarves and kimonos from an art history alone.</td>
|
|
115
|
+
</tr>
|
|
116
|
+
</table>
|
|
117
|
+
|
|
118
|
+
- [behaviorgpt.unboxai.com](https://behaviorgpt.unboxai.com/): a storefront, with recommendations, personalized search and a cart driven by the shopper's clicks. To try it on your catalog once it is embedded, select the **BehaviorGPT V4.0-12.5B** model, choose **Bring your own catalog** and enter your API key. Images must be publicly reachable for the demo to display them.
|
|
119
|
+
- [Curate my wall](https://github.com/Unbox-AI/aic-artworks) ([live app](https://aic-artworks.streamlit.app/)): an art recommender over 19 000 public-domain artworks from the Art Institute of Chicago. It shows the whole path for a catalog that isn't products: turning an open dataset into a catalog, embedding it, and driving recommendations, search, similar items and the embedding map from a visitor's clicks.
|
|
120
|
+
- **Art to fashion.** Its fashion pilot puts H&M clothing in the same catalog as the art, and the "Fashion for you" tab ranks it from the visitor's art clicks alone: kimonos for Japanese prints, beaded bracelets for ancient Egypt, statement earrings for portraits. No event in the history involves clothing. It works because both are in one catalog, so a history of art clicks ranks the clothing too; a `filters` query keeps the art grids art-only.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
behaviorgpt/__init__.py,sha256=WuQvNsYB-9utGPSM5_JnThXDWadiUcFILLRjfUS3Sms,567
|
|
2
|
+
behaviorgpt/_client.py,sha256=EyecH4skfmDJoZgK5-RvdOKGoU6bxG-8B6RycWBVheA,9704
|
|
3
|
+
behaviorgpt/_exceptions.py,sha256=w6Pyv3OWZzjmWJE99_7qbsTeYDo9BmieDwDdFyRUodw,643
|
|
4
|
+
behaviorgpt/resources/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
behaviorgpt/resources/_shared.py,sha256=v9R5OSSvsZoKqnTG4TubR3hdTV5sehG3au3gTnp_Vvs,1151
|
|
6
|
+
behaviorgpt/resources/catalogs.py,sha256=W6Y5fTT9G_5DTabACPONcZF3rXNPfI3vgPHdxgGBjig,9926
|
|
7
|
+
behaviorgpt/resources/recommendations.py,sha256=Nfghb-Tn3KSecMADVgJr0ezi9fc1ZBHoh8winJ4UxGU,841
|
|
8
|
+
behaviorgpt/resources/requests.py,sha256=C_Amb8Z2jJ10V_f2qqVLVIPafbn0HSw6eXt_d5o6kuE,1153
|
|
9
|
+
behaviorgpt/resources/search.py,sha256=zPkAzuI4Wd5AWY__LtpOhCr2FjKIUoRqcGV1S0so6Ts,816
|
|
10
|
+
behaviorgpt/types/__init__.py,sha256=IkNlI23w2eJ7PLgZtgQuoyAcAsmYB6UY0PtBfsm7cqA,1213
|
|
11
|
+
behaviorgpt/types/domains.py,sha256=-5Icp5Ht6cjWEWMwaV_7bS_YogidWqPy6lduEQ9TB8k,155
|
|
12
|
+
behaviorgpt/types/events.py,sha256=NoOvXaVM1qAWcxiT4AE9IzUhle94GnwXIPMWk5hosFU,2990
|
|
13
|
+
behaviorgpt/types/http.py,sha256=jhUIxp7t4PUVT58CXvyu560coTIFOUiO20X8T3ejhbE,7252
|
|
14
|
+
behaviorgpt-0.1.0.dist-info/licenses/LICENSE,sha256=KcHDCiQNYi6VcKVJ1WweRNLLZGhaurYfRDFtQsQLcUg,1065
|
|
15
|
+
behaviorgpt-0.1.0.dist-info/WHEEL,sha256=xTCBFIZIIyRSxyowOv5Wqg_jFU6KsdIffuulBB09Ph8,80
|
|
16
|
+
behaviorgpt-0.1.0.dist-info/METADATA,sha256=I6hebGae26y2wSh1TUk6NrVTwzm077sO9UzG9lSU9Bo,9303
|
|
17
|
+
behaviorgpt-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Unbox AI
|
|
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.
|