diff --git a/docs/catalog-format.md b/docs/catalog-format.md index bc788ee..69bf513 100644 --- a/docs/catalog-format.md +++ b/docs/catalog-format.md @@ -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 diff --git a/docs/catalogs.md b/docs/catalogs.md index 9cf9865..4642ec3 100644 --- a/docs/catalogs.md +++ b/docs/catalogs.md @@ -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. diff --git a/notebooks/showcase.ipynb b/notebooks/showcase.ipynb index a2aace2..53803dc 100644 --- a/notebooks/showcase.ipynb +++ b/notebooks/showcase.ipynb @@ -21,7 +21,7 @@ "\n", "You need an API key. Create yours up at unboxai.com/behaviorgpt; 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." ] @@ -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", " )" ] @@ -218,7 +218,7 @@ "metadata": {}, "outputs": [], "source": [ - "QUERY = \"sneakers\"\n", + "QUERY = \"shoes\"\n", "\n", "results = {}\n", "for label, history in {\n", @@ -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." ] }, { @@ -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))" ] }, { diff --git a/pyproject.toml b/pyproject.toml index 465ccab..fe5a3fd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/src/behaviorgpt/_client.py b/src/behaviorgpt/_client.py index b5487d2..04f5c85 100644 --- a/src/behaviorgpt/_client.py +++ b/src/behaviorgpt/_client.py @@ -1,5 +1,7 @@ +import asyncio import os import random +import time import warnings from collections import defaultdict from datetime import UTC, datetime, timedelta @@ -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 @@ -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"] @@ -499,7 +502,7 @@ 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 @@ -507,9 +510,20 @@ def umap(self, *, catalog_id: Optional[str] = None): 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): @@ -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: diff --git a/tests/test_async.py b/tests/test_async.py index 033b95c..fb96902 100644 --- a/tests/test_async.py +++ b/tests/test_async.py @@ -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="map")) + ) + assert asyncio.run(client.umap(catalog_id="cat", wait=60)) == "map" + + def test_calls_run_concurrently_on_one_client(): client, requests = mock_client() diff --git a/tests/test_client.py b/tests/test_client.py index 136edfd..d3e4a31 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -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="map")] + + +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) == "map" + 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 diff --git a/uv.lock b/uv.lock index 2d41f1d..3edeb5a 100644 --- a/uv.lock +++ b/uv.lock @@ -151,7 +151,7 @@ wheels = [ [[package]] name = "behaviorgpt" -version = "0.6.1" +version = "0.6.2" source = { editable = "." } dependencies = [ { name = "httpx" },