From 70419a6a753f14c8bba14f633cdd84ca26823711 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rickard=20Br=C3=BCel=20Gabrielsson?= Date: Thu, 22 Oct 2026 12:29:39 -0400 Subject: [PATCH 1/2] add_keywords runs fast mode by default, and always names the mode (#48) Co-authored-by: Cursor --- .claude/skills/embed-catalog/SKILL.md | 2 +- README.md | 2 +- docs/catalogs.md | 4 ++-- src/behaviorgpt/_client.py | 26 +++++++++++++------------- src/behaviorgpt/resources/catalogs.py | 16 ++++++++-------- tests/test_add_keywords.py | 20 +++++++++++--------- 6 files changed, 36 insertions(+), 34 deletions(-) diff --git a/.claude/skills/embed-catalog/SKILL.md b/.claude/skills/embed-catalog/SKILL.md index 555ac28..5969dd7 100644 --- a/.claude/skills/embed-catalog/SKILL.md +++ b/.claude/skills/embed-catalog/SKILL.md @@ -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. diff --git a/README.md b/README.md index 17a827c..a77d81e 100644 --- a/README.md +++ b/README.md @@ -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]) ``` diff --git a/docs/catalogs.md b/docs/catalogs.md index 2ded723..9cf9865 100644 --- a/docs/catalogs.md +++ b/docs/catalogs.md @@ -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. diff --git a/src/behaviorgpt/_client.py b/src/behaviorgpt/_client.py index 6411692..b5487d2 100644 --- a/src/behaviorgpt/_client.py +++ b/src/behaviorgpt/_client.py @@ -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), @@ -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, @@ -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`. """ @@ -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, diff --git a/src/behaviorgpt/resources/catalogs.py b/src/behaviorgpt/resources/catalogs.py index 39c0d6d..63ce6f1 100644 --- a/src/behaviorgpt/resources/catalogs.py +++ b/src/behaviorgpt/resources/catalogs.py @@ -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: @@ -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, @@ -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)) @@ -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, diff --git a/tests/test_add_keywords.py b/tests/test_add_keywords.py index 4c36517..8dfbd22 100644 --- a/tests/test_add_keywords.py +++ b/tests/test_add_keywords.py @@ -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(): @@ -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 From 54a5aa56e6a855bbda219c0f7349b898afe45f0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rickard=20Br=C3=BCel=20Gabrielsson?= Date: Thu, 22 Oct 2026 12:29:52 -0400 Subject: [PATCH 2/2] Release 0.6.1 Co-authored-by: Cursor --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 9c1ec6b..465ccab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/uv.lock b/uv.lock index 42a4c13..2d41f1d 100644 --- a/uv.lock +++ b/uv.lock @@ -151,7 +151,7 @@ wheels = [ [[package]] name = "behaviorgpt" -version = "0.6.0" +version = "0.6.1" source = { editable = "." } dependencies = [ { name = "httpx" },