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 .claude/skills/embed-catalog/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ The file name (without `.parquet`) is the catalog's name. Uploading the same nam

If the user only wants to add a few items to a catalog they already embedded, or change some of its items, build a parquet of just those items and upload it with `client.add_items(path, catalog_id=..., wait=True)` instead: only those items are embedded, an `id` already in the catalog is replaced, and the catalog keeps serving. A later full `embed` of the catalog replaces everything, so their main catalog file should get the same rows.

If the user wants `client.sections`, the catalog needs keywords, which `embed` does not generate by default. Once the catalog is ready, run `client.add_keywords(job.catalog_id, wait=True)`. It re-embeds nothing, but it takes about 2 hours per 20,000 items, so run it in the background if the tool allows. Its default `timeout` of 4 hours covers about 35,000 items; pass a longer one for a larger catalog.
If the user wants `client.sections`, the catalog needs keywords, which `embed` does not generate by default. Once the catalog is ready, run `client.add_keywords(job.catalog_id, wait=True)`. It re-embeds nothing; a 19,000-item catalog took 12 minutes. `fast=False` gives each item more keywords but takes 1 to 2 hours per 20,000 items, so run that in the background if the tool allows; its default `timeout` of 4 hours covers at least 35,000 items.

Load `.env` first (`from dotenv import load_dotenv; load_dotenv()`) if the key is not already in the environment. Embedding takes several minutes; run it in the background if the tool allows.

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ client.add_items("new_items.parquet", catalog_id=job.catalog_id, wait=True)
client.list_catalogs()
client.delete_catalog("cat_...")

client.add_keywords(job.catalog_id, wait=True) # 1 to 2 hours per 20,000 items
client.add_keywords(job.catalog_id, wait=True) # 12 minutes for 19,000 items
for section in client.sections([Search("coat")], catalog_id=job.catalog_id).sections:
print(section.title, [item.data["name"] for item in section.items])
```
Expand Down
4 changes: 2 additions & 2 deletions docs/catalogs.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,8 @@ Or generate them during the upload, so the catalog goes live with them:
job = client.embed(path, keywords=True, wait=True, timeout=4 * 3600.0)
```

- Keyword generation runs at 150 to 270 items a minute, depending on the catalog: a 20,000-item catalog took 2 hours 16 minutes, and a 10,000-item one 37 minutes. `add_keywords` waits up to 4 hours by default, enough for at least 35,000 items; for a larger catalog, or with `embed(..., keywords=True)`, pass a longer `timeout`. Keyword jobs run one at a time, so a job can also wait for another catalog's to finish before it starts.
- `add_keywords(catalog_id, fast=True)` asks the language model once per group of about 16 similar items instead of once per item. It runs at about 1,600 items a minute, 6 to 10 times faster (a 19,000-item catalog took 12 minutes), but each item gets fewer keywords, about 3 instead of 9, so sections have less to group by.
- Keyword generation asks the language model once per group of about 16 similar items. It runs at about 1,600 items a minute: a 19,000-item catalog took 12 minutes. Keyword jobs run one at a time, so a job can also wait for another catalog's to finish before it starts. With `embed(..., keywords=True)`, pass a `timeout` that covers this too.
- `add_keywords(catalog_id, fast=False)` asks once per item instead. Each item gets more keywords, about 9 instead of 3, so sections have more to group by, but it runs at 150 to 270 items a minute, depending on the catalog: a 20,000-item catalog took 2 hours 16 minutes, and a 10,000-item one 37 minutes. `add_keywords` waits up to 4 hours by default, enough for at least 35,000 items this way; pass a longer `timeout` for a larger catalog.
- `add_keywords` re-embeds nothing, and the catalog keeps answering queries while it runs. The keywords appear when the job is ready. If it fails, the catalog stays as it was.
- With `embed(..., keywords=True)`, if keyword generation fails, the catalog still goes live, without keywords. The job's status then reads `keywords_skipped`, with the reason in `keywords_error`, and `sections` returns an empty list for that catalog.
- Uploading the catalog again with `embed` replaces its keywords. Pass `keywords=True` there, or call `add_keywords` again, to keep them.
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.0"
version = "0.6.1"
description = "Python client for BehaviorGPT, UnboxAI's Large Behavioral Model for search, recommendations and personalization"
readme = "README.md"
license = "MIT"
Expand Down
26 changes: 13 additions & 13 deletions src/behaviorgpt/_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -348,10 +348,11 @@ def embed(
time by default.

`keywords=True` also generates the search keywords and browse
categories `sections` groups items by. Allow a longer `timeout`: it
adds time before the catalog is ready, 1 to 2 hours per 20,000
items. If it fails the catalog still goes live, without them. Off by
default; `add_keywords` generates them for the catalog later.
categories `sections` groups items by, as `add_keywords` does by
default. Allow a longer `timeout`: it adds time before the catalog is
ready, 12 minutes for a 19,000-item catalog. If it fails the catalog
still goes live, without them. Off by default; `add_keywords`
generates them for the catalog later.
"""
return self.catalogs.embed(
Path(path),
Expand Down Expand Up @@ -403,7 +404,7 @@ def add_keywords(
self,
catalog_id: str,
*,
fast: bool = False,
fast: bool = True,
wait: bool = False,
interval: float = 5.0,
timeout: float = 4 * 3600.0,
Expand All @@ -414,17 +415,16 @@ def add_keywords(
groups items by, for a catalog embedded without them.

Nothing is re-embedded, and the catalog keeps serving while the job
runs; it takes 1 to 2 hours per 20,000 items, depending on the
catalog, so the default `timeout` of 4 hours covers at least 35,000.
Once the job is ready
runs; a 19,000-item catalog took 12 minutes. Once the job is ready
the catalog serves with keywords. If it fails, the catalog stays as it
was. Uploading the catalog again with `embed` replaces the keywords;
pass `keywords=True` there to keep them.

`fast=True` asks the language model once per group of about 16
similar items instead of once per item: 6 to 10 times faster, with
fewer keywords per item (about 3 instead of 9), so sections have less
to group by.
By default the language model is asked once per group of about 16
similar items. `fast=False` asks it once per item instead: each item
gets more keywords (about 9 instead of 3), so sections have more to
group by, but it is 6 to 10 times slower, 1 to 2 hours per 20,000
items; the default `timeout` of 4 hours covers at least 35,000.

`wait` and the arguments after it work as for `embed`.
"""
Expand Down Expand Up @@ -659,7 +659,7 @@ async def add_keywords(
self,
catalog_id: str,
*,
fast: bool = False,
fast: bool = True,
wait: bool = False,
interval: float = 5.0,
timeout: float = 4 * 3600.0,
Expand Down
16 changes: 8 additions & 8 deletions src/behaviorgpt/resources/catalogs.py
Original file line number Diff line number Diff line change
Expand Up @@ -315,9 +315,9 @@ def keywords_path(catalog_id: str) -> str:


def keywords_body(fast: bool) -> dict[str, Any]:
"""The keywords request's body: none for the server's default mode, so
the request is the same as before modes existed."""
return {"json": {"mode": "fast"}} if fast else {}
"""The keywords request's body. It always names the mode, so the job
runs in the one asked for whatever the server's default."""
return {"json": {"mode": "fast" if fast else "product"}}


def items_path(catalog_id: str) -> str:
Expand Down Expand Up @@ -541,7 +541,7 @@ def add_keywords(
self,
catalog_id: str,
*,
fast: bool = False,
fast: bool = True,
wait: bool = False,
interval: float = 5.0,
timeout: float = 4 * 3600.0,
Expand All @@ -554,9 +554,9 @@ def add_keywords(

The catalog keeps serving while the job runs and gets the keywords
once it is ready. If the job fails, the catalog stays as it was.
`fast` asks the language model once per group of similar items
instead of once per item. `wait` and the arguments after it work as
for `embed`.
`fast` (the default) asks the language model once per group of
similar items, `fast=False` once per item. `wait` and the arguments
after it work as for `embed`.
"""
response = self._client.post(keywords_path(catalog_id), **keywords_body(fast))
job = AddKeywordsJobDetails(**handle_response(response))
Expand Down Expand Up @@ -732,7 +732,7 @@ async def add_keywords(
self,
catalog_id: str,
*,
fast: bool = False,
fast: bool = True,
wait: bool = False,
interval: float = 5.0,
timeout: float = 4 * 3600.0,
Expand Down
20 changes: 11 additions & 9 deletions tests/test_add_keywords.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,8 @@ def test_add_keywords_starts_a_job_for_the_catalog():
(post,) = calls
assert post.method == "POST"
assert post.url.path == f"/v1/catalogs/{CATALOG}/keywords"
assert post.content == b""
assert post.headers["content-type"] == "application/json"
assert json.loads(post.content) == {"mode": "fast"}


def test_add_keywords_waits_through_the_keywords_and_the_ingest():
Expand Down Expand Up @@ -117,26 +118,27 @@ async def run():
assert len(calls) == 3


def test_fast_asks_for_the_fast_mode():
def test_not_fast_asks_for_the_product_mode():
client, calls = sync_client()
client.add_keywords(CATALOG, fast=True)
client.add_keywords(CATALOG, fast=False)

(post,) = calls
assert post.headers["content-type"] == "application/json"
assert json.loads(post.content) == {"mode": "fast"}
assert json.loads(post.content) == {"mode": "product"}


def test_fast_async():
def test_fast_by_default_async():
async def run():
calls: list[httpx.Request] = []
client = AsyncUnboxAIClient("us", api_key="ubx_test", base_url=BASE)
client._http_client._transport = httpx.MockTransport(handler(calls, [READY]))
async with client:
await client.add_keywords(CATALOG, fast=True)
await client.add_keywords(CATALOG)
await client.add_keywords(CATALOG, fast=False)
return calls

(post,) = asyncio.run(run())
assert json.loads(post.content) == {"mode": "fast"}
fast, product = asyncio.run(run())
assert json.loads(fast.content) == {"mode": "fast"}
assert json.loads(product.content) == {"mode": "product"}


# The progress bar
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