behaviorgpt 0.2.2__tar.gz → 0.4.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: behaviorgpt
3
- Version: 0.2.2
3
+ Version: 0.4.0
4
4
  Summary: Python client for BehaviorGPT, UnboxAI's Large Behavioral Model for search, recommendations and personalization
5
5
  Keywords: recommendations,personalization,search,behavioral model,unboxai
6
6
  Author: Unbox AI
@@ -123,11 +123,14 @@ The model can only return items it knows about. Out of the box the client uses `
123
123
 
124
124
  - [`docs/catalog-format.md`](https://github.com/Unbox-AI/behaviorgpt/blob/main/docs/catalog-format.md) lists the required columns and types.
125
125
  - A catalog can hold at most 100,000 items.
126
- - 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.
127
- - [`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.
126
+ - 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 until you delete one of them (see below); to raise the limit, contact alexander@unboxai.com.
127
+ - [`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. A CSV with the documented column names converts to the parquet structure with `catalog_from_csv`.
128
128
 
129
129
  ```python
130
- job = client.embed("my_catalog.parquet", wait=True, timeout=1800.0)
130
+ from behaviorgpt import catalog_from_csv
131
+
132
+ path = catalog_from_csv("my_catalog.csv") # writes my_catalog.parquet
133
+ job = client.embed(path, wait=True, timeout=1800.0)
131
134
  client.complete([Search("...")], catalog_id=job.catalog_id)
132
135
  ```
133
136
 
@@ -146,6 +149,19 @@ client.add_items("new_items.parquet", catalog_id=job.catalog_id, wait=True)
146
149
  - Uploading the catalog's file again with `embed` replaces the whole catalog, added items included. Put them in that file too to keep them.
147
150
  - A catalog embedded before `add_items` existed needs one more full upload before items can be added to it.
148
151
 
152
+ ### Listing and deleting catalogs
153
+
154
+ ```python
155
+ for c in client.list_catalogs():
156
+ print(c.catalog_id, c.catalog_name, c.status, c.owned)
157
+
158
+ client.delete_catalog("cat_...")
159
+ ```
160
+
161
+ - `list_catalogs()` returns your own catalogs, in any status, and the shared ones that are ready. `owned` marks your own.
162
+ - `delete_catalog()` removes one of your catalogs with its items and frees its slot. It cannot be undone.
163
+ - Deleting another key's catalog fails with status 404, the same as an unknown id. While an `embed` or `add_items` job runs for the catalog, deleting fails with status 409; try again once the job is ready or failed.
164
+
149
165
  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.
150
166
 
151
167
  ### Events
@@ -106,11 +106,14 @@ The model can only return items it knows about. Out of the box the client uses `
106
106
 
107
107
  - [`docs/catalog-format.md`](https://github.com/Unbox-AI/behaviorgpt/blob/main/docs/catalog-format.md) lists the required columns and types.
108
108
  - A catalog can hold at most 100,000 items.
109
- - 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.
110
- - [`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.
109
+ - 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 until you delete one of them (see below); to raise the limit, contact alexander@unboxai.com.
110
+ - [`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. A CSV with the documented column names converts to the parquet structure with `catalog_from_csv`.
111
111
 
112
112
  ```python
113
- job = client.embed("my_catalog.parquet", wait=True, timeout=1800.0)
113
+ from behaviorgpt import catalog_from_csv
114
+
115
+ path = catalog_from_csv("my_catalog.csv") # writes my_catalog.parquet
116
+ job = client.embed(path, wait=True, timeout=1800.0)
114
117
  client.complete([Search("...")], catalog_id=job.catalog_id)
115
118
  ```
116
119
 
@@ -129,6 +132,19 @@ client.add_items("new_items.parquet", catalog_id=job.catalog_id, wait=True)
129
132
  - Uploading the catalog's file again with `embed` replaces the whole catalog, added items included. Put them in that file too to keep them.
130
133
  - A catalog embedded before `add_items` existed needs one more full upload before items can be added to it.
131
134
 
135
+ ### Listing and deleting catalogs
136
+
137
+ ```python
138
+ for c in client.list_catalogs():
139
+ print(c.catalog_id, c.catalog_name, c.status, c.owned)
140
+
141
+ client.delete_catalog("cat_...")
142
+ ```
143
+
144
+ - `list_catalogs()` returns your own catalogs, in any status, and the shared ones that are ready. `owned` marks your own.
145
+ - `delete_catalog()` removes one of your catalogs with its items and frees its slot. It cannot be undone.
146
+ - Deleting another key's catalog fails with status 404, the same as an unknown id. While an `embed` or `add_items` job runs for the catalog, deleting fails with status 409; try again once the job is ready or failed.
147
+
132
148
  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.
133
149
 
134
150
  ### Events
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "behaviorgpt"
3
- version = "0.2.2"
3
+ version = "0.4.0"
4
4
  description = "Python client for BehaviorGPT, UnboxAI's Large Behavioral Model for search, recommendations and personalization"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "behaviorgpt"
3
- version = "0.2.2"
3
+ version = "0.4.0"
4
4
  description = "Python client for BehaviorGPT, UnboxAI's Large Behavioral Model for search, recommendations and personalization"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -5,7 +5,7 @@ from behaviorgpt._exceptions import (
5
5
  RateLimitError,
6
6
  UnboxAIError,
7
7
  )
8
- from behaviorgpt.resources.catalogs import ProgressPrinter
8
+ from behaviorgpt.resources.catalogs import ProgressPrinter, catalog_from_csv
9
9
  from behaviorgpt.types.events import (
10
10
  AddToCart,
11
11
  Order,
@@ -23,6 +23,7 @@ __all__ = [
23
23
  "UnboxAIClient",
24
24
  "AsyncUnboxAIClient",
25
25
  "ProgressPrinter",
26
+ "catalog_from_csv",
26
27
  "UnboxAIError",
27
28
  "AuthenticationError",
28
29
  "BadRequestError",
@@ -18,6 +18,7 @@ from behaviorgpt.types import (
18
18
  AddItemsJobDetails,
19
19
  AddToCart,
20
20
  CartItem,
21
+ CatalogSummary,
21
22
  Domains,
22
23
  EmbedJobDetails,
23
24
  Item,
@@ -35,6 +36,7 @@ from behaviorgpt.types import (
35
36
  # file is stored and queued: httpx's 5 s default is too short for both.
36
37
  DEFAULT_HTTP_TIMEOUT = httpx.Timeout(120.0, connect=10.0, write=300.0)
37
38
  DEFAULT_BASE_URL = "https://behaviorgpt-northeurope.api.unboxai.com/v1"
39
+ DEFAULT_TRAILING_SEARCHES = 3
38
40
 
39
41
 
40
42
  class _BaseClient:
@@ -74,6 +76,7 @@ class _BaseClient:
74
76
  catalog_id: Optional[str],
75
77
  filters: Optional[dict[str, Any]],
76
78
  register_event: bool,
79
+ max_trailing_searches: int = DEFAULT_TRAILING_SEARCHES,
77
80
  ) -> tuple[bool, UnboxAIRequest, dict]:
78
81
  """The request `complete` sends, whether it goes to search, and its headers."""
79
82
  active_catalog, request_headers = self._catalog_headers(catalog_id)
@@ -90,6 +93,7 @@ class _BaseClient:
90
93
  offset=offset,
91
94
  filters=filters,
92
95
  register_event=register_event,
96
+ max_trailing_searches=max_trailing_searches,
93
97
  )
94
98
  return True, req, request_headers
95
99
 
@@ -216,9 +220,22 @@ class UnboxAIClient(_BaseClient):
216
220
  catalog_id: Optional[str] = None,
217
221
  filters: Optional[dict[str, Any]] = None,
218
222
  register_event: bool = False,
223
+ max_trailing_searches: int = DEFAULT_TRAILING_SEARCHES,
219
224
  ) -> UnboxAIResponse:
225
+ """Rank the items most likely to come after `history`.
226
+
227
+ When the history ends in searches, up to `max_trailing_searches` of
228
+ them in a row, the query included, reach the model, so `nike` then
229
+ `shoes` reads as one intent.
230
+ """
220
231
  is_search, req, headers = self._complete_request(
221
- history, limit, offset, catalog_id, filters, register_event
232
+ history,
233
+ limit,
234
+ offset,
235
+ catalog_id,
236
+ filters,
237
+ register_event,
238
+ max_trailing_searches,
222
239
  )
223
240
  endpoint = self.search if is_search else self.recommendations
224
241
  return endpoint.create(payload=req, headers=headers)
@@ -290,6 +307,22 @@ class UnboxAIClient(_BaseClient):
290
307
  def job_status(self, job_id: str) -> JobStatus:
291
308
  return self.catalogs.get_job_status(job_id)
292
309
 
310
+ def list_catalogs(self) -> list[CatalogSummary]:
311
+ """The catalogs this key can query: its own, in any status, and the
312
+ shared ones that are ready. `owned` marks the key's own."""
313
+ return self.catalogs.list_catalogs()
314
+
315
+ def delete_catalog(self, catalog_id: str) -> None:
316
+ """Delete one of this key's catalogs, its items and its index, and
317
+ free its slot. This cannot be undone.
318
+
319
+ A catalog that is not this key's raises `UnboxAIError` with status
320
+ 404, the same as an unknown one. While an embed or `add_items` job runs
321
+ for the catalog, it raises with status 409; delete once the job is
322
+ ready or failed.
323
+ """
324
+ self.catalogs.delete_catalog(catalog_id)
325
+
293
326
  def similar_items(
294
327
  self,
295
328
  item_id: str,
@@ -397,9 +430,18 @@ class AsyncUnboxAIClient(_BaseClient):
397
430
  catalog_id: Optional[str] = None,
398
431
  filters: Optional[dict[str, Any]] = None,
399
432
  register_event: bool = False,
433
+ max_trailing_searches: int = DEFAULT_TRAILING_SEARCHES,
400
434
  ) -> UnboxAIResponse:
435
+ """Rank the items most likely to come after `history`; see
436
+ `UnboxAIClient.complete`."""
401
437
  is_search, req, headers = self._complete_request(
402
- history, limit, offset, catalog_id, filters, register_event
438
+ history,
439
+ limit,
440
+ offset,
441
+ catalog_id,
442
+ filters,
443
+ register_event,
444
+ max_trailing_searches,
403
445
  )
404
446
  endpoint = self.search if is_search else self.recommendations
405
447
  return await endpoint.create(payload=req, headers=headers)
@@ -454,6 +496,14 @@ class AsyncUnboxAIClient(_BaseClient):
454
496
  async def job_status(self, job_id: str) -> JobStatus:
455
497
  return await self.catalogs.get_job_status(job_id)
456
498
 
499
+ async def list_catalogs(self) -> list[CatalogSummary]:
500
+ """See `UnboxAIClient.list_catalogs`."""
501
+ return await self.catalogs.list_catalogs()
502
+
503
+ async def delete_catalog(self, catalog_id: str) -> None:
504
+ """See `UnboxAIClient.delete_catalog`."""
505
+ await self.catalogs.delete_catalog(catalog_id)
506
+
457
507
  async def similar_items(
458
508
  self,
459
509
  item_id: str,
@@ -1,18 +1,23 @@
1
+ import ast
1
2
  import asyncio
2
3
  import time
3
4
  import warnings
4
5
  from datetime import UTC, datetime
5
6
  from pathlib import Path
6
7
  from typing import Any, Callable, Optional
8
+ from urllib.parse import quote
7
9
 
8
10
  import httpx
9
11
  import pyarrow as pa
12
+ import pyarrow.compute as pc
13
+ import pyarrow.csv as pa_csv
10
14
  import pyarrow.parquet as pq
11
15
 
12
16
  from behaviorgpt._exceptions import UnboxAIError
13
17
  from behaviorgpt.resources._shared import handle_response
14
18
  from behaviorgpt.types import (
15
19
  AddItemsJobDetails,
20
+ CatalogSummary,
16
21
  EmbedJobDetails,
17
22
  JobStatus,
18
23
  SimilarItemsRequest,
@@ -93,6 +98,79 @@ def check_catalog_schema(path: Path) -> None:
93
98
  )
94
99
 
95
100
 
101
+ LIST_COLUMNS: dict[str, type] = {
102
+ "sales_since": int,
103
+ "search_keywords": str,
104
+ "keywords": str,
105
+ }
106
+
107
+
108
+ def _parse_list(column: str, cell: Optional[str]) -> Optional[list]:
109
+ """`[1, 2]` or `a, b` as a list of the column's item type; empty is null."""
110
+ if cell is None or not cell.strip():
111
+ return None
112
+ cast = LIST_COLUMNS[column]
113
+ try:
114
+ items = (
115
+ ast.literal_eval(cell) if cell.strip().startswith("[") else cell.split(",")
116
+ )
117
+ return [cast(str(item).strip()) for item in items if str(item).strip()]
118
+ except (ValueError, SyntaxError) as exc:
119
+ raise UnboxAIError(f"{column}: cannot read {cell!r} as a list") from exc
120
+
121
+
122
+ def catalog_from_csv(
123
+ csv_path: Path | str, parquet_path: Optional[Path | str] = None
124
+ ) -> Path:
125
+ """Convert a CSV with the columns of docs/catalog-format.md to a catalog
126
+ parquet, check it and return its path.
127
+
128
+ Every cell is read as text, so prices keep their formatting. List columns
129
+ take `[1, 2]` or `a, b`. Empty cells, and columns the CSV lacks, become
130
+ null; other columns are left out. Duplicate ids keep their first row. The
131
+ parquet goes next to the CSV unless `parquet_path` is given; its file name
132
+ becomes the catalog's name.
133
+ """
134
+ csv_path = Path(csv_path)
135
+ out = Path(parquet_path) if parquet_path else csv_path.with_suffix(".parquet")
136
+ try:
137
+ table = pa_csv.read_csv(
138
+ csv_path,
139
+ convert_options=pa_csv.ConvertOptions(
140
+ column_types={column: pa.string() for column in CATALOG_SCHEMA},
141
+ include_columns=list(CATALOG_SCHEMA),
142
+ include_missing_columns=True,
143
+ strings_can_be_null=True,
144
+ ),
145
+ )
146
+ except pa.ArrowInvalid as exc:
147
+ raise UnboxAIError(f"{csv_path} is not a readable CSV file: {exc}") from exc
148
+
149
+ first_rows = {}
150
+ for row, item_id in enumerate(table.column("id").to_pylist()):
151
+ first_rows.setdefault(item_id, row)
152
+ table = table.take(sorted(first_rows.values()))
153
+
154
+ columns = {}
155
+ for column, expected in CATALOG_SCHEMA.items():
156
+ values = table.column(column)
157
+ if column in LIST_COLUMNS:
158
+ values = pa.array(
159
+ [_parse_list(column, cell) for cell in values.to_pylist()],
160
+ type=expected,
161
+ )
162
+ elif expected != pa.string():
163
+ try:
164
+ values = pc.cast(values, expected)
165
+ except pa.ArrowInvalid as exc:
166
+ raise UnboxAIError(f"{column}: expected {expected}: {exc}") from exc
167
+ columns[column] = values
168
+
169
+ pq.write_table(pa.table(columns, schema=pa.schema(CATALOG_SCHEMA)), out)
170
+ check_catalog_schema(out)
171
+ return out
172
+
173
+
96
174
  def format_reference_time(reference_time: datetime | str) -> str:
97
175
  """Format `reference_time` for the upload; a `datetime` must be timezone-aware."""
98
176
  if isinstance(reference_time, str):
@@ -133,6 +211,16 @@ def items_path(catalog_id: str) -> str:
133
211
  return f"/catalogs/{catalog_id}/items"
134
212
 
135
213
 
214
+ def catalog_path(catalog_id: str) -> str:
215
+ if not catalog_id:
216
+ raise ValueError("catalog_id is required to delete a catalog")
217
+ return f"/catalogs/{quote(catalog_id, safe='')}"
218
+
219
+
220
+ def catalog_summaries(response: httpx.Response) -> list[CatalogSummary]:
221
+ return [CatalogSummary(**c) for c in handle_response(response)["catalogs"]]
222
+
223
+
136
224
  def similar_items_body(
137
225
  item_id: str, catalog_id: str, limit: int, offset: int, filters: Optional[dict]
138
226
  ) -> dict:
@@ -405,6 +493,12 @@ class Catalogs:
405
493
  def get_umap(self, *, catalog_id: str):
406
494
  return umap_page(self._client.get(f"/{catalog_id}/umap"))
407
495
 
496
+ def list_catalogs(self) -> list[CatalogSummary]:
497
+ return catalog_summaries(self._client.get("/catalogs"))
498
+
499
+ def delete_catalog(self, catalog_id: str) -> None:
500
+ handle_response(self._client.delete(catalog_path(catalog_id)))
501
+
408
502
 
409
503
  class AsyncCatalogs:
410
504
  """The async counterpart of `Catalogs`; see it for what each method does."""
@@ -520,3 +614,9 @@ class AsyncCatalogs:
520
614
 
521
615
  async def get_umap(self, *, catalog_id: str) -> str:
522
616
  return umap_page(await self._client.get(f"/{catalog_id}/umap"))
617
+
618
+ async def list_catalogs(self) -> list[CatalogSummary]:
619
+ return catalog_summaries(await self._client.get("/catalogs"))
620
+
621
+ async def delete_catalog(self, catalog_id: str) -> None:
622
+ handle_response(await self._client.delete(catalog_path(catalog_id)))
@@ -25,6 +25,7 @@ class UnboxAIRequester:
25
25
  register_event: bool = False,
26
26
  timestamp: Optional[datetime] = None,
27
27
  num_candidates: Optional[int] = None,
28
+ max_trailing_searches: Optional[int] = None,
28
29
  ) -> UnboxAIRequest:
29
30
  return UnboxAIRequest(
30
31
  query=query,
@@ -38,4 +39,5 @@ class UnboxAIRequester:
38
39
  register_event=register_event,
39
40
  timestamp=timestamp,
40
41
  num_candidates=num_candidates,
42
+ max_trailing_searches=max_trailing_searches,
41
43
  )
@@ -16,6 +16,7 @@ from behaviorgpt.types.events import (
16
16
  )
17
17
  from behaviorgpt.types.http import (
18
18
  AddItemsJobDetails,
19
+ CatalogSummary,
19
20
  EmbedJobDetails,
20
21
  Item,
21
22
  ItemsPage,
@@ -42,6 +43,7 @@ __all__ = [
42
43
  "SimilarItemsRequest",
43
44
  "EmbedJobDetails",
44
45
  "AddItemsJobDetails",
46
+ "CatalogSummary",
45
47
  "JobStatus",
46
48
  "ItemsRejected",
47
49
  "RejectedItem",
@@ -56,6 +56,10 @@ class UnboxAIRequest(BaseModel):
56
56
  timestamp: Optional[datetime] = None
57
57
  # How many nearest-neighbour candidates the server explores.
58
58
  num_candidates: Optional[int] = Field(default=None, gt=0, le=5_000)
59
+ # Search only: how many searches in a row at the end of the history, the
60
+ # query included, reach the model. Unset, the deployment's history settings
61
+ # apply.
62
+ max_trailing_searches: Optional[int] = Field(default=None, ge=1, le=10)
59
63
 
60
64
  @field_validator("timestamp")
61
65
  @classmethod
@@ -148,6 +152,17 @@ class AddItemsJobDetails(EmbedJobDetails):
148
152
  products: int
149
153
 
150
154
 
155
+ class CatalogSummary(BaseModel):
156
+ catalog_id: str
157
+ # display label derived from the uploaded file name, server-side
158
+ catalog_name: str
159
+ # 'pending', 'ready' or 'failed'; shared catalogs are listed only when ready
160
+ status: str
161
+ # uploaded with this API key, so it counts against the key's catalog slots
162
+ # and this key can delete it
163
+ owned: bool
164
+
165
+
151
166
  class SimilarItemsRequest(BaseModel):
152
167
  model_config = ConfigDict(serialize_by_alias=True)
153
168
 
File without changes