Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/catalog-format.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ Listed in order of impact.

## Catalog size and the UMAP

`client.umap()` returns a 2D map of the catalog's items. The map is built only for catalogs of up to 100,000 items, within about a minute after the catalog reads `ready`. Laying out more items than that does not fit in the embedding worker's memory, so larger catalogs, such as the shared `retail_catalog`, never get one. Embedding and every query work the same at any size; only the map is skipped. For a catalog without a map, `umap()` raises `UnboxAIError` with status 404. Items added with `client.add_items()` are not drawn on the map until the next full upload of the catalog.
`client.umap()` returns a 2D map of the catalog's items. The map is built only for catalogs of up to 100,000 items, within about a minute after the catalog reads `ready`. Laying out more items than that does not fit in the embedding worker's memory, so larger catalogs, such as the shared `retail_catalog`, never get one. Embedding and every query work the same at any size; only the map is skipped. For a catalog without a map, `umap()` raises `UnboxAIError` with status 404. Right after an embed, pass `wait=` (seconds) to keep asking until the map is built, for example `client.umap(catalog_id=job.catalog_id, wait=180)`. Items added with `client.add_items()` are not drawn on the map until the next full upload of the catalog.

## Hosting your catalog

Expand Down
2 changes: 1 addition & 1 deletion docs/catalogs.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ How catalogs are named and limited, how to update, list and delete them, and how
## Names and limits

- A catalog can hold at most 100,000 items. Items added later with `add_items` count too.
- Each API key can hold 2 catalogs. To raise the limit, contact alexander@unboxai.com.
- Each API key can hold 2 catalogs. To raise the limit, email admin@unboxai.com.
- The file name is the catalog's name. Uploading a file with the same name again refreshes that catalog and keeps its `catalog_id`; 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.
- One `add_items` or `add_keywords` job runs per catalog at a time, and the catalog cannot be refreshed or deleted while it runs.

Expand Down
12 changes: 6 additions & 6 deletions notebooks/showcase.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
"\n",
"You need an API key. Create yours up at <a href=\"https://unboxai.com/behaviorgpt\" target=\"_blank\" rel=\"noopener\">unboxai.com/behaviorgpt</a>; the key also arrives by email. Put it in a `.env` file at the repo root as `UNBOXAI_API_KEY` (see `.env.example`); without one, the notebook asks for it.\n",
"\n",
"The first cell installs the client if it is missing or older than 0.5, for example on Colab. In a clone set up with `uv sync` it does nothing.\n",
"The first cell installs the client if it is missing or older than 0.6.2, for example on Colab. In a clone set up with `uv sync` it does nothing.\n",
"\n",
"The client packages your event history into HTTP requests. `market` sets the locale and `default_catalog_id` picks the catalog every call uses unless told otherwise. Here we browse a sample e-commerce catalog as a user from the US."
]
Expand All @@ -40,14 +40,14 @@
"\n",
"try:\n",
" version = importlib.metadata.version(\"behaviorgpt\")\n",
" up_to_date = tuple(map(int, version.split(\".\")[:2])) >= (0, 5) # predict=\n",
" up_to_date = tuple(map(int, version.split(\".\")[:3])) >= (0, 6, 2) # umap(wait=)\n",
"except importlib.metadata.PackageNotFoundError:\n",
" up_to_date = False\n",
"\n",
"# A venv made by uv has no pip, so only call it when something is missing.\n",
"if not up_to_date or not all(importlib.util.find_spec(m) for m in (\"dotenv\", \"pandas\")):\n",
" subprocess.run(\n",
" [sys.executable, \"-m\", \"pip\", \"install\", \"-q\", \"behaviorgpt>=0.5\", \"python-dotenv\", \"pandas\"],\n",
" [sys.executable, \"-m\", \"pip\", \"install\", \"-q\", \"behaviorgpt>=0.6.2\", \"python-dotenv\", \"pandas\"],\n",
" check=True,\n",
" )"
]
Expand Down Expand Up @@ -218,7 +218,7 @@
"metadata": {},
"outputs": [],
"source": [
"QUERY = \"sneakers\"\n",
"QUERY = \"shoes\"\n",
"\n",
"results = {}\n",
"for label, history in {\n",
Expand Down Expand Up @@ -294,7 +294,7 @@
"source": [
"## Step 6. Inspect the space\n",
"\n",
"Each item is now a vector. Similar items land close together, unrelated ones far apart, and search and recommendation become nearest-neighbor lookups. The plot below projects the space to two dimensions."
"Each item is now a vector. Similar items land close together, unrelated ones far apart, and search and recommendation become nearest-neighbor lookups. The plot below projects the space to two dimensions. The map is drawn within about a minute after the catalog is ready, so the cell waits up to three minutes for it."
]
},
{
Expand All @@ -305,7 +305,7 @@
"outputs": [],
"source": [
"from IPython.display import HTML\n",
"HTML(client.umap(catalog_id=job.catalog_id))"
"HTML(client.umap(catalog_id=job.catalog_id, wait=180))"
]
},
{
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "behaviorgpt"
version = "0.6.1"
version = "0.6.2"
description = "Python client for BehaviorGPT, UnboxAI's Large Behavioral Model for search, recommendations and personalization"
readme = "README.md"
license = "MIT"
Expand Down
31 changes: 26 additions & 5 deletions src/behaviorgpt/_client.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import asyncio
import os
import random
import time
import warnings
from collections import defaultdict
from datetime import UTC, datetime, timedelta
Expand All @@ -9,7 +11,7 @@

import httpx

from behaviorgpt._exceptions import AuthenticationError
from behaviorgpt._exceptions import AuthenticationError, UnboxAIError
from behaviorgpt.resources.catalogs import AsyncCatalogs, Catalogs
from behaviorgpt.resources.recommendations import AsyncRecommender, Recommender
from behaviorgpt.resources.requests import UnboxAIRequester
Expand Down Expand Up @@ -42,6 +44,7 @@
DEFAULT_HTTP_TIMEOUT = httpx.Timeout(120.0, connect=10.0, write=300.0)
DEFAULT_BASE_URL = "https://behaviorgpt-northeurope.api.unboxai.com/v1"
DEFAULT_TRAILING_SEARCHES = 3
UMAP_POLL = 5.0

Predict = Literal["view", "purchase"]

Expand Down Expand Up @@ -499,17 +502,28 @@ def random_product(self, *args: Any, **kwargs: Any) -> Item:
)
return self.random_item(*args, **kwargs)

def umap(self, *, catalog_id: Optional[str] = None):
def umap(self, *, catalog_id: Optional[str] = None, wait: float = 0.0) -> str:
"""The catalog's UMAP, a 2D map of its items, as an HTML page.

Maps exist only for catalogs of up to 100,000 items, and appear within
about a minute after the catalog is ready: laying out more items than
that does not fit in the embedding worker's memory, so larger catalogs
(such as the shared `retail_catalog`) never get one. A missing map
raises `UnboxAIError` with status 404 and says which case applies.

`wait` is how many seconds to keep asking while the map is missing,
for a catalog that just became ready. A catalog that never gets a map
raises only once that time is up.
"""
active_catalog: str = catalog_id or self.default_catalog_id
return self.catalogs.get_umap(catalog_id=active_catalog)
deadline = time.monotonic() + wait
while True:
try:
return self.catalogs.get_umap(catalog_id=active_catalog)
except UnboxAIError as err:
if err.status_code != 404 or time.monotonic() + UMAP_POLL > deadline:
raise
time.sleep(UMAP_POLL)


class AsyncUnboxAIClient(_BaseClient):
Expand Down Expand Up @@ -713,10 +727,17 @@ async def random_item(
top = await self.complete(history=[], catalog_id=catalog_id, limit=window)
return _pick_random(top, catalog_id or self.default_catalog_id)

async def umap(self, *, catalog_id: Optional[str] = None) -> str:
async def umap(self, *, catalog_id: Optional[str] = None, wait: float = 0.0) -> str:
"""The catalog's UMAP as an HTML page; see `UnboxAIClient.umap`."""
active_catalog: str = catalog_id or self.default_catalog_id
return await self.catalogs.get_umap(catalog_id=active_catalog)
deadline = time.monotonic() + wait
while True:
try:
return await self.catalogs.get_umap(catalog_id=active_catalog)
except UnboxAIError as err:
if err.status_code != 404 or time.monotonic() + UMAP_POLL > deadline:
raise
await asyncio.sleep(UMAP_POLL)


def _pick_random(top: UnboxAIResponse, catalog_id: str) -> Item:
Expand Down
13 changes: 13 additions & 0 deletions tests/test_async.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,19 @@ def test_umap_returns_the_page_and_raises_on_error():
assert exc.value.status_code == 404


def test_umap_wait_asks_again_until_the_map_is_built(monkeypatch):
async def no_sleep(_):
pass

monkeypatch.setattr("behaviorgpt._client.asyncio.sleep", no_sleep)
answers = iter([httpx.Response(404, json={"detail": "No UMAP"})] * 2)
client = AsyncUnboxAIClient("US", api_key="test", base_url=BASE)
client._http_client._transport = httpx.MockTransport(
lambda _: next(answers, httpx.Response(200, text="<html>map</html>"))
)
assert asyncio.run(client.umap(catalog_id="cat", wait=60)) == "<html>map</html>"


def test_calls_run_concurrently_on_one_client():
client, requests = mock_client()

Expand Down
57 changes: 57 additions & 0 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,63 @@ def test_umap_error_raises_unboxai_error():
assert exc.value.status_code == 404


def map_after(misses: int) -> list[httpx.Response]:
"""A UMAP endpoint that 404s `misses` times, then serves the page."""
return [
httpx.Response(404, json={"detail": "No UMAP for this catalog."})
] * misses + [httpx.Response(200, text="<html>map</html>")]


def client_answering_in_turn(
responses: list[httpx.Response],
) -> tuple[UnboxAIClient, list]:
client = UnboxAIClient("US", api_key="test", base_url="http://sdk.test/v1")
calls: list[httpx.Request] = []

def handler(request: httpx.Request) -> httpx.Response:
calls.append(request)
return responses[min(len(calls), len(responses)) - 1]

client._http_client._transport = httpx.MockTransport(handler)
return client, calls


def test_umap_wait_asks_again_until_the_map_is_built(monkeypatch):
monkeypatch.setattr("behaviorgpt._client.time.sleep", lambda _: None)
client, calls = client_answering_in_turn(map_after(2))
assert client.umap(catalog_id="cat", wait=60) == "<html>map</html>"
assert len(calls) == 3


def test_umap_wait_gives_up_at_the_deadline(monkeypatch):
clock = [0.0]
monkeypatch.setattr("behaviorgpt._client.time.monotonic", lambda: clock[0])
monkeypatch.setattr(
"behaviorgpt._client.time.sleep", lambda s: clock.__setitem__(0, clock[0] + s)
)
client, calls = client_answering_in_turn(map_after(100))
with pytest.raises(UnboxAIError, match="No UMAP"):
client.umap(catalog_id="cat", wait=12)
assert len(calls) == 3


def test_umap_without_wait_asks_once():
client, calls = client_answering_in_turn(map_after(1))
with pytest.raises(UnboxAIError):
client.umap(catalog_id="cat")
assert len(calls) == 1


def test_umap_wait_does_not_retry_other_errors(monkeypatch):
monkeypatch.setattr("behaviorgpt._client.time.sleep", lambda _: None)
client, calls = client_answering_in_turn(
[httpx.Response(401, json={"detail": "bad key"})]
)
with pytest.raises(UnboxAIError):
client.umap(catalog_id="cat", wait=60)
assert len(calls) == 1


def test_error_classes_import_from_the_package_root():
import behaviorgpt

Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading