From 45fa8d0016c04ea9cf6886c184d238445cc39ccd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rickard=20Br=C3=BCel=20Gabrielsson?= Date: Sat, 24 Oct 2026 15:45:14 -0400 Subject: [PATCH 1/3] catalog_ids: rank several catalogs together complete, sections and similar_items take catalog_ids instead of catalog_id to rank up to 5 catalogs as one, e.g. your own next to retail_catalog. Every Item now has catalog_id, from the response or the request's catalog, since an id is unique only within its catalog. History events name their item's catalog with catalog_id=, or take an Item of a response, which knows its own; an order of items from several catalogs is sent as one order per catalog. In filters, catalog_id is the item's catalog. An item that names a catalog the request does not read is refused before anything is sent. Co-authored-by: Cursor --- README.md | 10 +- docs/catalogs.md | 21 ++- src/behaviorgpt/_client.py | 245 ++++++++++++++++++++++++++------ src/behaviorgpt/types/events.py | 45 ++++-- src/behaviorgpt/types/http.py | 13 +- tests/test_multi_catalog.py | 206 +++++++++++++++++++++++++++ 6 files changed, 476 insertions(+), 64 deletions(-) create mode 100644 tests/test_multi_catalog.py diff --git a/README.md b/README.md index a77d81e..0cb061f 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,15 @@ for section in client.sections([Search("coat")], catalog_id=job.catalog_id).sect print(section.title, [item.data["name"] for item in section.items]) ``` -[`docs/catalogs.md`](https://github.com/Unbox-AI/behaviorgpt/blob/main/docs/catalogs.md) has the details: catalog slots, what replacing an item keeps, how long keywords take and what happens when a job fails. +To rank several catalogs together, say your own next to `retail_catalog`, pass `catalog_ids` instead of `catalog_id`. One ranking comes back, and each item says which catalog it is in: + +```python +res = client.complete([View(shoe)], catalog_ids=[job.catalog_id, "retail_catalog"]) +for item in res.items: + print(item.catalog_id, item.data["name"]) +``` + +[`docs/catalogs.md`](https://github.com/Unbox-AI/behaviorgpt/blob/main/docs/catalogs.md) has the details: catalog slots, what replacing an item keeps, how long keywords take, what happens when a job fails, and how a history names items of several catalogs. 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. diff --git a/docs/catalogs.md b/docs/catalogs.md index 4642ec3..ed7f657 100644 --- a/docs/catalogs.md +++ b/docs/catalogs.md @@ -1,6 +1,6 @@ # Managing catalogs -How catalogs are named and limited, how to update, list and delete them, and how to add the keywords that `sections` needs. For what the uploaded file must contain, see [catalog-format.md](catalog-format.md). +How catalogs are named and limited, how to update, list and delete them, how to rank several together, and how to add the keywords that `sections` needs. For what the uploaded file must contain, see [catalog-format.md](catalog-format.md). ## Names and limits @@ -36,6 +36,25 @@ client.delete_catalog("cat_...") - `delete_catalog()` removes one of your catalogs with its items and frees its slot. It cannot be undone. - Deleting another key's catalog fails with status 404, the same as an unknown id. While a job runs for the catalog, deleting fails with status 409; try again once the job is ready or failed. +## Several catalogs at once + +`complete`, `sections` and `similar_items` take `catalog_ids` instead of `catalog_id` to rank up to 5 catalogs together, for instance your own catalog and `retail_catalog`. The model reads the history once and scores the items of every catalog on the same scale, so the result is one ranking, not one per catalog. + +```python +both = [job.catalog_id, "retail_catalog"] +res = client.complete([View(shoe)], catalog_ids=both) +for item in res.items: + print(item.catalog_id, item.id, item.data["name"]) +``` + +- An item id is unique only within its catalog, so every item of a response says which catalog it is in, `item.catalog_id`. Single-catalog responses fill it in too. +- An item in a history is in the first catalog of `catalog_ids` unless it names another: `View("sku-1", catalog_id="retail_catalog")`. An `Item` from a response knows its catalog, so `View(item)` and `Order([item])` need nothing more. An order can mix items of several catalogs. +- In `filters`, `catalog_id` is the item's catalog: `{"catalog_id": "retail_catalog"}` keeps only its items, and `{"$not": {"catalog_id": job.catalog_id}}` leaves yours out. +- `similar_items(item, catalog_ids=both)` finds the items most like `item` in all of them. The item is in its own catalog if it is an `Item`, else in `catalog_id`, else in the first. +- `sections` weighs each catalog by its number of items when it groups them, so a small catalog does not crowd out a large one. +- An item that names a catalog the request does not read is refused before anything is sent. +- The catalogs must have been embedded by the same model, as their scores are compared. A catalog embedded by an older model is refused with status 409; upload it again to embed it with the current one. + ## Keywords and sections Sections group the items the model ranks under titles such as "black coats", best section first. They are built from item keywords, which `embed` does not generate by default because they take time. Generate them for an embedded catalog with `add_keywords`: diff --git a/src/behaviorgpt/_client.py b/src/behaviorgpt/_client.py index 04f5c85..1a95333 100644 --- a/src/behaviorgpt/_client.py +++ b/src/behaviorgpt/_client.py @@ -4,9 +4,10 @@ import time import warnings from collections import defaultdict +from dataclasses import dataclass from datetime import UTC, datetime, timedelta from pathlib import Path -from typing import Any, Callable, Literal, Optional, Sequence, cast +from typing import Any, Callable, Iterable, Literal, Optional, Sequence, cast from uuid import uuid4 import httpx @@ -36,7 +37,9 @@ UnboxAIResponse, UserEvent, UserHistoryInput, + View, ) +from behaviorgpt.types.events import ItemRef from behaviorgpt.types.http import RecommendationType # An embed upload streams the whole parquet, and the API answers only once the @@ -47,6 +50,45 @@ UMAP_POLL = 5.0 Predict = Literal["view", "purchase"] +CATALOG_IDS_HEADER = "x-catalog-ids" + + +@dataclass(frozen=True) +class _Target: + """The catalogs one request reads. Several (`catalog_ids=`) are ranked + together, and then every item names the catalog it is in.""" + + catalog_ids: tuple[str, ...] + multi: bool = False + + @property + def primary(self) -> str: + return self.catalog_ids[0] + + @property + def headers(self) -> dict: + if self.multi: + return {CATALOG_IDS_HEADER: ",".join(self.catalog_ids)} + return {"x-catalog-id": self.primary} + + def of(self, catalog_id: Optional[str]) -> str: + """The catalog of an item that names `catalog_id`, or none.""" + if catalog_id is None or catalog_id == self.primary: + return self.primary + if catalog_id not in self.catalog_ids: + hint = "" if self.multi else "; pass catalog_ids= to rank several" + raise ValueError( + f"an item is in catalog {catalog_id!r}, but this request reads " + f"{', '.join(map(repr, self.catalog_ids))}{hint}" + ) + return catalog_id + + def label(self, items: Iterable[Item]) -> None: + """Give each item of a response its catalog: the API names it only when + the request reads several.""" + for item in items: + if item.catalog_id is None: + item.catalog_id = self.primary class _BaseClient: @@ -74,9 +116,38 @@ def __init__( self.requester = UnboxAIRequester(domains=self.domains, timezone=self.timezone) - def _catalog_headers(self, catalog_id: Optional[str]) -> tuple[str, dict]: - active_catalog: str = catalog_id or self.default_catalog_id - return active_catalog, {"x-catalog-id": active_catalog} + def _target( + self, catalog_id: Optional[str], catalog_ids: Optional[Sequence[str]] + ) -> _Target: + if catalog_ids is None: + return _Target((catalog_id or self.default_catalog_id,)) + if catalog_id is not None: + raise ValueError("pass catalog_id or catalog_ids, not both") + if isinstance(catalog_ids, str): + raise TypeError("catalog_ids takes a list of catalog ids, not a string") + ids = tuple(dict.fromkeys(catalog_ids)) + if not ids: + raise ValueError("catalog_ids names no catalog") + return _Target(ids, multi=True) + + def _similar_request( + self, + item: ItemRef, + catalog_id: Optional[str], + catalog_ids: Optional[Sequence[str]], + ) -> tuple[_Target, str, str]: + """The catalogs a similar-items request reads, and the id and catalog + of its item. The item's catalog is `catalog_id`, else the `Item`'s own; + over `catalog_ids` it must be one of them, by default the first.""" + item_id = item if isinstance(item, str) else item.id + item_catalog = catalog_id or ( + None if isinstance(item, str) else item.catalog_id + ) + if catalog_ids is None: + target = self._target(item_catalog, None) + return target, item_id, target.primary + target = self._target(None, catalog_ids) + return target, item_id, target.of(item_catalog) def _complete_request( self, @@ -88,13 +159,15 @@ def _complete_request( register_event: bool, max_trailing_searches: int = DEFAULT_TRAILING_SEARCHES, predict: Predict = "view", - ) -> tuple[bool, UnboxAIRequest, dict]: - """The request `complete` sends, whether it goes to search, and its headers.""" + catalog_ids: Optional[Sequence[str]] = None, + ) -> tuple[bool, UnboxAIRequest, _Target]: + """The request `complete` sends, whether it goes to search, and the + catalogs it reads.""" if predict not in ("view", "purchase"): raise ValueError(f"predict must be 'view' or 'purchase', not {predict!r}") - active_catalog, request_headers = self._catalog_headers(catalog_id) + target = self._target(catalog_id, catalog_ids) - history_events = self.__from_sequence(active_catalog, history) + history_events = self.__from_sequence(target, history) last_event = self._get_last_event(history) if isinstance(last_event, Search): @@ -106,18 +179,18 @@ def _complete_request( req = self.requester.make( query=last_event.query, history=history_events[:-1], - catalog_id=active_catalog, + catalog_id=target.primary, limit=limit, offset=offset, filters=filters, register_event=register_event, max_trailing_searches=max_trailing_searches, ) - return True, req, request_headers + return True, req, target req = self.requester.make( history=history_events, - catalog_id=active_catalog, + catalog_id=target.primary, limit=limit, offset=offset, filters=filters, @@ -126,7 +199,7 @@ def _complete_request( RecommendationType(name="buy_next") if predict == "purchase" else None ), ) - return False, req, request_headers + return False, req, target def _sections_request( self, @@ -137,18 +210,19 @@ def _sections_request( max_sections: int, max_items_per_section: int, min_items_per_section: int, - ) -> tuple[dict, dict]: - """The body `sections` sends, and its headers.""" - _, req, headers = self._complete_request( - history, 10, 0, catalog_id, filters, register_event + catalog_ids: Optional[Sequence[str]] = None, + ) -> tuple[dict, _Target]: + """The body `sections` sends, and the catalogs it reads.""" + _, req, target = self._complete_request( + history, 10, 0, catalog_id, filters, register_event, catalog_ids=catalog_ids ) body = sections_body( req, max_sections, max_items_per_section, min_items_per_section ) - return body, headers + return body, target def __from_sequence( - self, catalog_id: str, context: UserHistoryInput + self, target: _Target, context: UserHistoryInput ) -> list[SessionEvent]: events: list[SessionEvent] = [] @@ -159,7 +233,7 @@ def __from_sequence( def _process_session_events( session_events: Sequence[UserEvent], session_id: str, session_offset: int ): - cart_state: dict[str, int] = defaultdict(int) + cart_state: dict[tuple[Optional[str], str], int] = defaultdict(int) current_cart_id = str(uuid4()) # Base time for the start of the sequence @@ -167,17 +241,19 @@ def _process_session_events( for i, ev in enumerate(session_events): if isinstance(ev, AddToCart): - cart_state[ev.item] += 1 + cart_state[(ev.catalog_id, ev.item)] += 1 elif isinstance(ev, RemoveFromCart): - if cart_state[ev.item] > 1: - cart_state[ev.item] -= 1 + key = (ev.catalog_id, ev.item) + if cart_state[key] > 1: + cart_state[key] -= 1 else: - cart_state.pop(ev.item) + cart_state.pop(key) elif isinstance(ev, Order): if not getattr(ev, "items", None): ev.cart_id = current_cart_id ev.items = [ - CartItem(item, qty) for item, qty in cart_state.items() + CartItem(item, qty, catalog_id=catalog) + for (catalog, item), qty in cart_state.items() ] cart_state.clear() current_cart_id = str(uuid4()) @@ -189,9 +265,15 @@ def _process_session_events( milliseconds=(session_offset * 1000) + i ) - events.append( - self.__to_session_evt(ev, session_id, catalog_id, event_time) - ) + for j, (catalog, event) in enumerate(_by_catalog(ev, target)): + events.append( + self.__to_session_evt( + event, + session_id, + catalog, + event_time + timedelta(microseconds=j), + ) + ) if isinstance(context[0], (list, tuple)): context_2d = cast(Sequence[Sequence[UserEvent]], context) @@ -264,6 +346,7 @@ def complete( offset: int = 0, *, catalog_id: Optional[str] = None, + catalog_ids: Optional[Sequence[str]] = None, filters: Optional[dict[str, Any]] = None, register_event: bool = False, max_trailing_searches: int = DEFAULT_TRAILING_SEARCHES, @@ -280,8 +363,16 @@ def complete( When the history ends in searches, up to `max_trailing_searches` of them in a row, the query included, reach the model, so `nike` then `shoes` reads as one intent. + + `catalog_ids` ranks several catalogs together instead of one, for + instance your own and `retail_catalog`: each item of the result says + which it is in (`item.catalog_id`). Items in the history are in the + first catalog unless they name another, `View(item_id, + catalog_id=...)`, or are `Item`s of an earlier response, which know + theirs. In `filters`, `catalog_id` is the item's catalog, e.g. + `{"catalog_id": "retail_catalog"}`. """ - is_search, req, headers = self._complete_request( + is_search, req, target = self._complete_request( history, limit, offset, @@ -290,15 +381,19 @@ def complete( register_event, max_trailing_searches, predict, + catalog_ids, ) endpoint = self.search if is_search else self.recommendations - return endpoint.create(payload=req, headers=headers) + response = endpoint.create(payload=req, headers=target.headers) + target.label(response.items) + return response def sections( self, history: UserHistoryInput, *, catalog_id: Optional[str] = None, + catalog_ids: Optional[Sequence[str]] = None, max_sections: int = 10, max_items_per_section: int = 25, min_items_per_section: int = 5, @@ -317,8 +412,12 @@ def sections( `max_sections` can be at most 15 and `max_items_per_section` at most 100. Each item's `data` holds its generated search terms in `top_terms`, best first. + + `catalog_ids` groups several catalogs together, as for `complete`: a + section can hold items of any of them, each weighed by its catalog's + size, so a small catalog does not crowd out a large one. """ - body, headers = self._sections_request( + body, target = self._sections_request( history, catalog_id, filters, @@ -326,8 +425,11 @@ def sections( max_sections, max_items_per_section, min_items_per_section, + catalog_ids, ) - return self._sections.create(body, headers=headers) + response = self._sections.create(body, headers=target.headers) + target.label(item for section in response.sections for item in section.items) + return response def embed( self, @@ -462,20 +564,34 @@ def delete_catalog(self, catalog_id: str) -> None: def similar_items( self, - item_id: str, + item_id: ItemRef, limit: int = 10, offset: int = 0, *, catalog_id: Optional[str] = None, + catalog_ids: Optional[Sequence[str]] = None, + filters: Optional[dict[str, Any]] = None, ) -> UnboxAIResponse: - active_catalog, request_headers = self._catalog_headers(catalog_id) - return self.catalogs.get_similar_items( - item_id, - catalog_id=active_catalog, + """The items most like `item_id`, an item id or an `Item` of a + response, most similar first. + + `catalog_id` is the catalog the item is in; for an `Item`, its own. + `catalog_ids` looks for similar items in several catalogs, the item's + among them (by default the first): each result says which it is in. + """ + target, item, item_catalog = self._similar_request( + item_id, catalog_id, catalog_ids + ) + response = self.catalogs.get_similar_items( + item, + catalog_id=item_catalog, limit=limit, offset=offset, - headers=request_headers, + filters=filters, + headers=target.headers, ) + target.label(response.items) + return response def similar_products( self, product_id: str, *args: Any, **kwargs: Any @@ -577,6 +693,7 @@ async def complete( offset: int = 0, *, catalog_id: Optional[str] = None, + catalog_ids: Optional[Sequence[str]] = None, filters: Optional[dict[str, Any]] = None, register_event: bool = False, max_trailing_searches: int = DEFAULT_TRAILING_SEARCHES, @@ -584,7 +701,7 @@ async def complete( ) -> UnboxAIResponse: """Rank the items most likely to come after `history`; see `UnboxAIClient.complete`.""" - is_search, req, headers = self._complete_request( + is_search, req, target = self._complete_request( history, limit, offset, @@ -593,15 +710,19 @@ async def complete( register_event, max_trailing_searches, predict, + catalog_ids, ) endpoint = self.search if is_search else self.recommendations - return await endpoint.create(payload=req, headers=headers) + response = await endpoint.create(payload=req, headers=target.headers) + target.label(response.items) + return response async def sections( self, history: UserHistoryInput, *, catalog_id: Optional[str] = None, + catalog_ids: Optional[Sequence[str]] = None, max_sections: int = 10, max_items_per_section: int = 25, min_items_per_section: int = 5, @@ -609,7 +730,7 @@ async def sections( register_event: bool = False, ) -> SectionsResponse: """Items grouped into titled sections; see `UnboxAIClient.sections`.""" - body, headers = self._sections_request( + body, target = self._sections_request( history, catalog_id, filters, @@ -617,8 +738,11 @@ async def sections( max_sections, max_items_per_section, min_items_per_section, + catalog_ids, ) - return await self._sections.create(body, headers=headers) + response = await self._sections.create(body, headers=target.headers) + target.label(item for section in response.sections for item in section.items) + return response async def embed( self, @@ -705,20 +829,28 @@ async def delete_catalog(self, catalog_id: str) -> None: async def similar_items( self, - item_id: str, + item_id: ItemRef, limit: int = 10, offset: int = 0, *, catalog_id: Optional[str] = None, + catalog_ids: Optional[Sequence[str]] = None, + filters: Optional[dict[str, Any]] = None, ) -> UnboxAIResponse: - active_catalog, request_headers = self._catalog_headers(catalog_id) - return await self.catalogs.get_similar_items( - item_id, - catalog_id=active_catalog, + """The items most like `item_id`; see `UnboxAIClient.similar_items`.""" + target, item, item_catalog = self._similar_request( + item_id, catalog_id, catalog_ids + ) + response = await self.catalogs.get_similar_items( + item, + catalog_id=item_catalog, limit=limit, offset=offset, - headers=request_headers, + filters=filters, + headers=target.headers, ) + target.label(response.items) + return response async def random_item( self, catalog_id: Optional[str] = None, window: int = 100 @@ -740,6 +872,25 @@ async def umap(self, *, catalog_id: Optional[str] = None, wait: float = 0.0) -> await asyncio.sleep(UMAP_POLL) +def _by_catalog(event: UserEvent, target: _Target) -> list[tuple[str, UserEvent]]: + """An event as the API reads it, each with the catalog its items are in. + An event names one catalog, so an order of items from several becomes one + order per catalog.""" + if isinstance(event, Order) and event.items: + groups: dict[str, list[CartItem]] = {} + for item in event.items: + groups.setdefault(target.of(item.catalog_id), []).append(item) + if len(groups) == 1: + return [(next(iter(groups)), event)] + return [ + (catalog, event.model_copy(update={"items": items})) + for catalog, items in groups.items() + ] + if isinstance(event, (View, AddToCart, RemoveFromCart)): + return [(target.of(event.catalog_id), event)] + return [(target.primary, event)] + + def _pick_random(top: UnboxAIResponse, catalog_id: str) -> Item: if not top.items: raise ValueError(f"catalog {catalog_id!r} returned no items") diff --git a/src/behaviorgpt/types/events.py b/src/behaviorgpt/types/events.py index ad057d2..3fe1dd2 100644 --- a/src/behaviorgpt/types/events.py +++ b/src/behaviorgpt/types/events.py @@ -1,11 +1,17 @@ import warnings from datetime import UTC, datetime -from typing import List, Literal, Optional, Sequence, Tuple, Union +from typing import TYPE_CHECKING, List, Literal, Optional, Sequence, Tuple, Union from pydantic import AliasChoices, BaseModel, ConfigDict, Field from behaviorgpt.types.domains import Domains +if TYPE_CHECKING: + from behaviorgpt.types.http import Item + +# an item id, or an `Item` of a response, which knows its catalog +ItemRef = Union[str, "Item"] + class _ItemEvent(BaseModel): # the API calls the item "product" @@ -15,8 +21,12 @@ class _ItemEvent(BaseModel): validation_alias=AliasChoices("item", "product"), serialization_alias="product", ) + # the catalog the item is in, for a request over several catalogs + # (`catalog_ids=`); unset, the request's first catalog. Not sent as part of + # the event: it becomes the event's store_id. + catalog_id: Optional[str] = Field(default=None, exclude=True) - def __init__(self, item: Optional[str] = None, **data): + def __init__(self, item: Optional[ItemRef] = None, **data): if "product" in data: warnings.warn( "`product=` is deprecated, use `item=`", @@ -25,6 +35,10 @@ def __init__(self, item: Optional[str] = None, **data): ) product = data.pop("product") item = product if item is None else item + if item is not None and not isinstance(item, str): + if data.get("catalog_id") is None: + data["catalog_id"] = item.catalog_id + item = item.id if item is not None: data["item"] = item super().__init__(**data) @@ -41,7 +55,7 @@ class CartItem(_ItemEvent): quantity: int def __init__( - self, item: Optional[str] = None, quantity: Optional[int] = None, **data + self, item: Optional[ItemRef] = None, quantity: Optional[int] = None, **data ): if quantity is not None: data["quantity"] = quantity @@ -65,7 +79,7 @@ class View(_ItemEvent): class AddToCart(_ItemEvent): type: Literal["addToCart"] = "addToCart" - def __init__(self, item: Optional[str] = None, **data): + def __init__(self, item: Optional[ItemRef] = None, **data): warnings.warn( "`AddToCart` is deprecated and will be removed in 0.7: the model reads " "it the same as `View`. Send a `View`, and list what was bought in " @@ -79,7 +93,7 @@ def __init__(self, item: Optional[str] = None, **data): class RemoveFromCart(_ItemEvent): type: Literal["removeFromCart"] = "removeFromCart" - def __init__(self, item: Optional[str] = None, **data): + def __init__(self, item: Optional[ItemRef] = None, **data): warnings.warn( "`RemoveFromCart` is deprecated and will be removed in 0.7: it does not " "change results. Leave the item out of `Order([...])` instead", @@ -89,13 +103,16 @@ def __init__(self, item: Optional[str] = None, **data): super().__init__(item, **data) -OrderItem = Union[str, CartItem, Tuple[str, int]] +OrderItem = Union[ItemRef, CartItem, Tuple[ItemRef, int]] class Order(BaseModel): - """A finished purchase of `items`: item ids, `(id, quantity)` pairs or - `CartItem`s. The model reads only which items were bought; quantities and - `cart_id` do not change results.""" + """A finished purchase of `items`: item ids, `Item`s, `(item, quantity)` + pairs or `CartItem`s. The model reads only which items were bought; + quantities and `cart_id` do not change results. + + In a request over several catalogs the items can come from any of them: + each is in the catalog its `Item` or `CartItem(catalog_id=)` names.""" type: Literal["order"] = "order" @@ -135,12 +152,12 @@ def __init__( def _order_item(entry: OrderItem) -> CartItem: - if isinstance(entry, str): - return CartItem(entry, 1) + if isinstance(entry, CartItem): + return entry if isinstance(entry, tuple): - item_id, quantity = entry - return CartItem(item_id, quantity) - return entry + item, quantity = entry + return CartItem(item, quantity) + return CartItem(entry, 1) UserEvent = Union[Search, AddToCart, View, RemoveFromCart, Order] diff --git a/src/behaviorgpt/types/http.py b/src/behaviorgpt/types/http.py index 07d83be..7dd928e 100644 --- a/src/behaviorgpt/types/http.py +++ b/src/behaviorgpt/types/http.py @@ -22,6 +22,11 @@ class Item(BaseModel): id: str data: dict score: float + # the catalog the item is in: an id is unique only within its catalog. + # "store_id" on the wire. + catalog_id: Optional[str] = Field( + default=None, validation_alias=AliasChoices("catalog_id", "store_id") + ) def extract_price(self, market: str | None = None) -> Optional[float]: if "price" in self.data: @@ -139,7 +144,12 @@ def to_pandas(self) -> "pd.DataFrame": items_data = [] for item in self.items: - flat_item = {"id": item.id, "score": item.score, **item.data} + flat_item = { + "id": item.id, + "catalog_id": item.catalog_id, + "score": item.score, + **item.data, + } flat_item["price"] = item.extract_price(self.market) items_data.append(flat_item) @@ -183,6 +193,7 @@ class SimilarItemsRequest(BaseModel): validation_alias=AliasChoices("item_id", "product_id"), serialization_alias="product_id", ) + # the catalog of the item; the results can come from several store_id: str limit: int = Field(default=10, ge=1, le=100) offset: int = Field(default=0, ge=0) diff --git a/tests/test_multi_catalog.py b/tests/test_multi_catalog.py new file mode 100644 index 0000000..2e34c1a --- /dev/null +++ b/tests/test_multi_catalog.py @@ -0,0 +1,206 @@ +"""Ranking several catalogs together: `catalog_ids=` sends x-catalog-ids, each +history event names the catalog of its items, and every item of a response +knows its catalog.""" + +import asyncio +import json + +import httpx +import pytest + +from behaviorgpt import AsyncUnboxAIClient, UnboxAIClient +from behaviorgpt.types import CartItem, Item, Order, Search, View + + +class Api: + """Records the requests and answers each like the API: items carry a + store_id only when the request reads several catalogs.""" + + def __init__(self) -> None: + self.requests: list[httpx.Request] = [] + + def handler(self, request: httpx.Request) -> httpx.Response: + self.requests.append(request) + multi = "x-catalog-ids" in request.headers + item = {"id": "p1", "data": {"name": "P1"}, "score": 0.5} + if multi: + item["store_id"] = "retail_catalog" + if request.url.path.endswith("/sections"): + return httpx.Response( + 200, json={"sections": [{"title": "coats", "products": [item]}]} + ) + return httpx.Response( + 200, json={"products": {"items": [item], "offset": 0, "limit": 10}} + ) + + @property + def last(self) -> httpx.Request: + return self.requests[-1] + + @property + def body(self) -> dict: + return json.loads(self.last.content) + + +@pytest.fixture +def api() -> Api: + return Api() + + +@pytest.fixture +def client(api: Api) -> UnboxAIClient: + client = UnboxAIClient("SE", api_key="test", base_url="http://sdk.test/v1") + client._http_client._transport = httpx.MockTransport(api.handler) + return client + + +MINE = "cat_mine" +BOTH = [MINE, "retail_catalog"] + + +def test_a_single_catalog_request_is_unchanged(client: UnboxAIClient, api: Api): + resp = client.complete([View("p1")], catalog_id=MINE) + + assert api.last.headers["x-catalog-id"] == MINE + assert "x-catalog-ids" not in api.last.headers + assert [e["store_id"] for e in api.body["history"]] == [MINE] + # the API does not name the catalog of a single-catalog request's items + assert resp.items[0].catalog_id == MINE + + +def test_catalog_ids_ranks_several_together(client: UnboxAIClient, api: Api): + resp = client.complete( + [View("p1"), View("r9", catalog_id="retail_catalog")], + catalog_ids=BOTH, + filters={"catalog_id": "retail_catalog"}, + ) + + assert api.last.headers["x-catalog-ids"] == "cat_mine,retail_catalog" + assert "x-catalog-id" not in api.last.headers + assert api.body["store_id"] == MINE + assert api.body["filters"] == {"catalog_id": "retail_catalog"} + history = api.body["history"] + assert [(e["store_id"], e["event"]["product"]) for e in history] == [ + (MINE, "p1"), + ("retail_catalog", "r9"), + ] + # the event's catalog is its store_id, not a field of the event + assert "catalog_id" not in history[1]["event"] + assert resp.items[0].catalog_id == "retail_catalog" + + +def test_items_of_a_response_carry_their_catalog_into_the_next( + client: UnboxAIClient, api: Api +): + first = client.complete([], catalog_ids=BOTH) + client.complete([View(first.items[0])], catalog_ids=BOTH) + + (event,) = api.body["history"] + assert (event["store_id"], event["event"]["product"]) == ("retail_catalog", "p1") + + +def test_an_order_of_several_catalogs_is_one_order_per_catalog( + client: UnboxAIClient, api: Api +): + retail_item = Item(id="r1", data={}, score=1.0, catalog_id="retail_catalog") + client.complete( + [Order(["p1", (retail_item, 2), CartItem("p2", 1)], cart_id="c1")], + catalog_ids=BOTH, + ) + + orders = api.body["history"] + assert [ + (o["store_id"], [i["product"] for i in o["event"]["items"]]) for o in orders + ] == [(MINE, ["p1", "p2"]), ("retail_catalog", ["r1"])] + assert {o["event"]["cart_id"] for o in orders} == {"c1"} + first, second = (o["timestamp"] for o in orders) + assert first < second + + +def test_a_search_is_in_the_first_catalog(client: UnboxAIClient, api: Api): + client.complete( + [View("r1", catalog_id="retail_catalog"), Search("coats")], catalog_ids=BOTH + ) + assert api.last.url.path.endswith("/vector/search") + assert api.body["query"] == "coats" + assert [e["store_id"] for e in api.body["history"]] == ["retail_catalog"] + + +@pytest.mark.parametrize( + "kwargs, error", + [ + ({"catalog_ids": BOTH, "catalog_id": MINE}, "not both"), + ({"catalog_ids": []}, "names no catalog"), + ({"catalog_ids": "cat_mine"}, "list of catalog ids"), + ], +) +def test_catalog_ids_are_checked(client: UnboxAIClient, kwargs: dict, error: str): + with pytest.raises((ValueError, TypeError), match=error): + client.complete([], **kwargs) + + +def test_an_item_of_a_catalog_not_read_is_refused(client: UnboxAIClient, api: Api): + with pytest.raises(ValueError, match="'cat_other'.*catalog_ids="): + client.complete([View("x", catalog_id="cat_other")], catalog_id=MINE) + with pytest.raises(ValueError, match="'cat_other'"): + client.complete([View("x", catalog_id="cat_other")], catalog_ids=BOTH) + assert api.requests == [] + + +def test_sections_over_several_catalogs(client: UnboxAIClient, api: Api): + resp = client.sections([View("p1")], catalog_ids=BOTH) + assert api.last.headers["x-catalog-ids"] == "cat_mine,retail_catalog" + assert resp.sections[0].items[0].catalog_id == "retail_catalog" + + +def test_similar_items_of_an_item_in_one_catalog_over_several( + client: UnboxAIClient, api: Api +): + item = Item(id="r1", data={}, score=1.0, catalog_id="retail_catalog") + resp = client.similar_items(item, catalog_ids=BOTH, filters={"brand": "x"}) + + assert api.last.headers["x-catalog-ids"] == "cat_mine,retail_catalog" + assert api.body["product_id"] == "r1" + assert api.body["store_id"] == "retail_catalog" + assert api.body["filters"] == {"brand": "x"} + assert resp.items[0].catalog_id == "retail_catalog" + + client.similar_items("p1", catalog_ids=BOTH) + assert api.body["store_id"] == MINE + client.similar_items("p1", catalog_id="retail_catalog", catalog_ids=BOTH) + assert api.body["store_id"] == "retail_catalog" + with pytest.raises(ValueError, match="'cat_other'"): + client.similar_items("p1", catalog_id="cat_other", catalog_ids=BOTH) + + +def test_similar_items_of_an_item_reads_its_catalog(client: UnboxAIClient, api: Api): + item = Item(id="r1", data={}, score=1.0, catalog_id="retail_catalog") + client.similar_items(item) + assert api.last.headers["x-catalog-id"] == "retail_catalog" + assert api.body["store_id"] == "retail_catalog" + + +def test_to_pandas_names_the_catalog(client: UnboxAIClient): + pytest.importorskip("pandas") + frame = client.complete([], catalog_ids=BOTH).to_pandas() + assert frame.loc[0, "catalog_id"] == "retail_catalog" + + +def test_the_async_client_reads_several_catalogs(api: Api): + async def run(): + async with AsyncUnboxAIClient( + "SE", api_key="test", base_url="http://sdk.test/v1" + ) as client: + client._http_client._transport = httpx.MockTransport(api.handler) + return ( + await client.complete([View("p1")], catalog_ids=BOTH), + await client.sections([], catalog_ids=BOTH), + await client.similar_items("p1", catalog_ids=BOTH), + ) + + resp, sections, similar = asyncio.run(run()) + + assert all("x-catalog-ids" in r.headers for r in api.requests) + assert resp.items[0].catalog_id == "retail_catalog" + assert sections.sections[0].items[0].catalog_id == "retail_catalog" + assert similar.items[0].catalog_id == "retail_catalog" From befd77c0fcf0cc9af735f85b06392fc1b5ec1fb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rickard=20Br=C3=BCel=20Gabrielsson?= Date: Sat, 24 Oct 2026 15:50:22 -0400 Subject: [PATCH 2/3] Export CartItem, so an order line can name its catalog Co-authored-by: Cursor --- src/behaviorgpt/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/behaviorgpt/__init__.py b/src/behaviorgpt/__init__.py index 72dda62..cc94d4b 100644 --- a/src/behaviorgpt/__init__.py +++ b/src/behaviorgpt/__init__.py @@ -12,6 +12,7 @@ ) from behaviorgpt.types.events import ( AddToCart, + CartItem, Order, RemoveFromCart, Search, @@ -24,6 +25,7 @@ "View", "RemoveFromCart", "Order", + "CartItem", "UnboxAIClient", "AsyncUnboxAIClient", "ProgressBar", From 2901950c64232c5001388442ed19ea25574bf9b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rickard=20Br=C3=BCel=20Gabrielsson?= Date: Sat, 24 Oct 2026 16:18:44 -0400 Subject: [PATCH 3/3] UNBOXAI_CATALOG_ID: the default catalog, so calls need no catalog_id Co-authored-by: Cursor --- .env.example | 1 + README.md | 2 ++ src/behaviorgpt/_client.py | 13 +++++++++---- tests/test_multi_catalog.py | 11 +++++++++++ 4 files changed, 23 insertions(+), 4 deletions(-) diff --git a/.env.example b/.env.example index d5329b4..7f01faa 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,3 @@ UNBOXAI_API_KEY= # the key you receive in your email +UNBOXAI_CATALOG_ID= # optional: your catalog, used when a call names none (default retail_catalog) HF_TOKEN= # optional: Hugging Face write token, only needed to host catalog images (see docs/catalog-format.md) diff --git a/README.md b/README.md index 0cb061f..05cb544 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,8 @@ job = client.embed(path, wait=True, timeout=1800.0) client.complete([Search("...")], catalog_id=job.catalog_id) ``` +To make your catalog the default, so calls need no `catalog_id`, set `UNBOXAI_CATALOG_ID=cat_...` or pass `default_catalog_id=` to the client. + A catalog holds up to 100,000 items, and each API key holds 2 catalogs. The file name is the catalog's name, so uploading the same name again refreshes that catalog. Once it is embedded, you can add or replace items, list and delete catalogs, and generate the keywords that `sections` uses to group results under titles such as "black coats": diff --git a/src/behaviorgpt/_client.py b/src/behaviorgpt/_client.py index 1a95333..e79ce50 100644 --- a/src/behaviorgpt/_client.py +++ b/src/behaviorgpt/_client.py @@ -101,7 +101,7 @@ def __init__( timezone: str = "UTC", api_key: Optional[str] = None, base_url: str = DEFAULT_BASE_URL, - default_catalog_id: str = "retail_catalog", + default_catalog_id: Optional[str] = None, ): self.api_key = api_key or os.environ.get("UNBOXAI_API_KEY") if not self.api_key: @@ -109,7 +109,12 @@ def __init__( raise AuthenticationError(msg) self.base_url = base_url - self.default_catalog_id = default_catalog_id + # the catalog of calls that name none + self.default_catalog_id = ( + default_catalog_id + or os.environ.get("UNBOXAI_CATALOG_ID") + or "retail_catalog" + ) self.domains = Domains(market=market) self.timezone = timezone @@ -318,7 +323,7 @@ def __init__( timezone: str = "UTC", api_key: Optional[str] = None, base_url: str = DEFAULT_BASE_URL, - default_catalog_id: str = "retail_catalog", + default_catalog_id: Optional[str] = None, http_timeout: float | httpx.Timeout = DEFAULT_HTTP_TIMEOUT, ): super().__init__( @@ -656,7 +661,7 @@ def __init__( timezone: str = "UTC", api_key: Optional[str] = None, base_url: str = DEFAULT_BASE_URL, - default_catalog_id: str = "retail_catalog", + default_catalog_id: Optional[str] = None, http_timeout: float | httpx.Timeout = DEFAULT_HTTP_TIMEOUT, ): super().__init__( diff --git a/tests/test_multi_catalog.py b/tests/test_multi_catalog.py index 2e34c1a..888a907 100644 --- a/tests/test_multi_catalog.py +++ b/tests/test_multi_catalog.py @@ -204,3 +204,14 @@ async def run(): assert resp.items[0].catalog_id == "retail_catalog" assert sections.sections[0].items[0].catalog_id == "retail_catalog" assert similar.items[0].catalog_id == "retail_catalog" + + +def test_the_default_catalog_is_the_argument_then_the_environment(monkeypatch): + monkeypatch.setenv("UNBOXAI_API_KEY", "k") + monkeypatch.delenv("UNBOXAI_CATALOG_ID", raising=False) + assert UnboxAIClient("se").default_catalog_id == "retail_catalog" + monkeypatch.setenv("UNBOXAI_CATALOG_ID", "cat_env") + assert UnboxAIClient("se").default_catalog_id == "cat_env" + assert AsyncUnboxAIClient("se").default_catalog_id == "cat_env" + client = UnboxAIClient("se", default_catalog_id="cat_arg") + assert client.default_catalog_id == "cat_arg"