From 728d3e559c74c1a3f8ba24ad0de8e7b9bb760cf5 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Sat, 27 Jun 2026 15:09:36 +0200 Subject: [PATCH] Centralize exact subcategory selectors --- category_extensions.py | 2 +- category_library.py | 33 +++++++++++-- docs/prompt-pool-routing-map.md | 7 +++ pair_options.py | 11 ++++- tools/prompt_smoke.py | 86 ++++++++++++++++++++++++++++++++- 5 files changed, 131 insertions(+), 8 deletions(-) diff --git a/category_extensions.py b/category_extensions.py index db13530..b741cf2 100644 --- a/category_extensions.py +++ b/category_extensions.py @@ -113,5 +113,5 @@ def subcategory_choices() -> list[str]: choices = [category_policy.RANDOM_SUBCATEGORY] for category in category_policy.load_category_library(): for subcategory in category["subcategories"]: - choices.append(f"{category['name']} / {subcategory['name']}") + choices.append(category_policy.exact_subcategory_selector(category, subcategory)) return choices diff --git a/category_library.py b/category_library.py index 2092381..48a9337 100644 --- a/category_library.py +++ b/category_library.py @@ -422,6 +422,30 @@ def find_category(categories: list[dict[str, Any]], name_or_slug: str) -> dict[s return None +def exact_subcategory_selector(category: dict[str, Any], subcategory: dict[str, Any]) -> str: + return f"{category.get('name')} / {subcategory.get('name')}" + + +def split_exact_subcategory_choice( + categories: list[dict[str, Any]], + subcategory_choice: str, +) -> tuple[dict[str, Any], str] | None: + choice = str(subcategory_choice or "").strip() + if not choice or " / " not in choice: + return None + candidates: list[tuple[int, dict[str, Any], str]] = [] + for category in categories: + for category_label in (category.get("name", ""), category.get("slug", "")): + category_label = str(category_label).strip() + prefix = f"{category_label} / " + if category_label and choice.lower().startswith(prefix.lower()): + candidates.append((len(prefix), category, choice[len(prefix) :].strip())) + if candidates: + _length, category, subcategory_name = max(candidates, key=lambda candidate: candidate[0]) + return category, subcategory_name + return None + + def _base_cast_counts(women_count: int, men_count: int) -> tuple[int, int]: women_count = max(0, int(women_count)) men_count = max(0, int(men_count)) @@ -467,10 +491,11 @@ def find_subcategory( ) -> tuple[dict[str, Any], dict[str, Any], int, int]: women_count, men_count = _base_cast_counts(women_count, men_count) if subcategory_choice and subcategory_choice != random_subcategory and " / " in subcategory_choice: - category_name, subcategory_name = subcategory_choice.split(" / ", 1) - category = find_category(categories, category_name) - if not category: + exact_choice = split_exact_subcategory_choice(categories, subcategory_choice) + if not exact_choice: + category_name = str(subcategory_choice).split(" / ", 1)[0] raise ValueError(f"Unknown category in subcategory picker: {category_name}") + category, subcategory_name = exact_choice wanted = subcategory_name.strip().lower() for subcategory in category["subcategories"]: if subcategory["name"].lower() == wanted or subcategory["slug"].lower() == wanted: @@ -485,7 +510,7 @@ def find_subcategory( f"women_count={women_count}, men_count={men_count}" ) return category, subcategory, adjusted_women_count, adjusted_men_count - raise ValueError(f"Unknown subcategory '{subcategory_name}' for category '{category_name}'") + raise ValueError(f"Unknown subcategory '{subcategory_name}' for category '{category['name']}'") if category_choice == "custom_random": if not categories: diff --git a/docs/prompt-pool-routing-map.md b/docs/prompt-pool-routing-map.md index ab1b346..d3daf26 100644 --- a/docs/prompt-pool-routing-map.md +++ b/docs/prompt-pool-routing-map.md @@ -233,6 +233,13 @@ There are two category systems. JSON categories are the scalable system. Add new main categories or subcategories there unless the behavior needs Python logic. +Exact JSON subcategory selection uses the full selector returned by +`category_library.exact_subcategory_selector`: `Category name / Subcategory +name`. The resolver parses that selector against the loaded category library +instead of blindly splitting the first slash separator, so custom category or +subcategory names may themselves contain `/` without drifting to a sibling +route. + ## JSON Category Road ```mermaid diff --git a/pair_options.py b/pair_options.py index c261f48..920f30a 100644 --- a/pair_options.py +++ b/pair_options.py @@ -4,6 +4,11 @@ import json import re from typing import Any +try: + from . import category_library as category_policy +except ImportError: # Allows local smoke tests from the repository root. + import category_library as category_policy + INSTA_OF_SOFT_LEVELS = { "social_tease": "Instagram-style thirst-trap post, suggestive polished social feed energy", @@ -409,7 +414,11 @@ def softcore_category(level: str) -> tuple[str, str]: level, INSTA_OF_SOFTCORE_SUBCATEGORY_BY_LEVEL["lingerie_tease"], ) - category, _subcategory = subcategory.split(" / ", 1) + exact_choice = category_policy.split_exact_subcategory_choice( + category_policy.load_category_library(), + subcategory, + ) + category = exact_choice[0]["name"] if exact_choice else subcategory.split(" / ", 1)[0] return category, subcategory diff --git a/tools/prompt_smoke.py b/tools/prompt_smoke.py index c672441..b2ea47f 100644 --- a/tools/prompt_smoke.py +++ b/tools/prompt_smoke.py @@ -285,7 +285,7 @@ def _character_cast_subjects(subjects: list[str] | tuple[str, ...]) -> str: def _exact_subcategory_selector(category: dict[str, Any], subcategory: dict[str, Any]) -> str: - return f"{category.get('name')} / {subcategory.get('name')}" + return category_library.exact_subcategory_selector(category, subcategory) def _matrix_cast_for_route(category: dict[str, Any], subcategory: dict[str, Any]) -> tuple[int, int, str]: @@ -1878,7 +1878,24 @@ def smoke_category_extensions_policy() -> None: "compositions": ["centered catalog frame"], } }, - } + }, + "Dev / Test Wear": { + "prompt_template": ( + "{subject_phrase}: slash-name test style. {item_label}: {item}. " + "Scene: {scene}. Pose: {pose}. Facial expression: {expression}. " + "Composition: {composition}." + ), + "caption_template": "{subject_phrase}, {item}, {scene}, {composition}", + "subcategories": { + "Layered / Office": { + "items": ["slash-safe structured blazer over tailored trousers"], + "scenes": ["slash-safe showroom with glass shelving"], + "poses": ["standing beside a mirrored divider"], + "expressions": ["focused exact-route look"], + "compositions": ["slash-safe centered catalog frame"], + } + }, + }, } }, ensure_ascii=True, @@ -1890,6 +1907,12 @@ def smoke_category_extensions_policy() -> None: "Dev Test Wear / Layered Office" in pb.subcategory_choices(), "User-added JSON subcategory did not reach subcategory choices", ) + slash_selector = "Dev / Test Wear / Layered / Office" + _expect("Dev / Test Wear" in pb.category_choices(), "Slash-bearing user category did not reach category choices") + _expect( + slash_selector in pb.subcategory_choices(), + "Slash-bearing user subcategory did not reach subcategory choices", + ) row = pb.build_prompt( category="Dev Test Wear", subcategory="Dev Test Wear / Layered Office", @@ -1922,6 +1945,36 @@ def smoke_category_extensions_policy() -> None: _expect(row.get("expression") == "focused calm look", "User-added JSON expression did not generate") _expect(row.get("composition") == "centered catalog frame", "User-added JSON composition did not generate") _expect_formatter_outputs(row, "category_extensions_user_added_json", target="single") + slash_row = pb.build_prompt( + category="Dev / Test Wear", + subcategory=slash_selector, + row_number=1, + start_index=1, + seed=4110, + clothing="random", + ethnicity="any", + poses="random", + backside_bias=0.0, + figure="random", + no_plus_women=False, + no_black=False, + minimal_clothing_ratio=0.0, + standard_pose_ratio=1.0, + trigger=Trigger, + prepend_trigger_to_prompt=True, + extra_positive="", + extra_negative="", + women_count=1, + men_count=0, + ) + _expect_row_base(slash_row, "category_extensions_slash_named_json") + _expect(slash_row.get("main_category") == "Dev / Test Wear", "Slash-bearing JSON category name drifted") + _expect(slash_row.get("subcategory") == "Layered / Office", "Slash-bearing JSON subcategory name drifted") + _expect( + slash_row.get("item") == "slash-safe structured blazer over tailored trousers", + "Slash-bearing JSON exact subcategory did not generate the intended item", + ) + _expect_formatter_outputs(slash_row, "category_extensions_slash_named_json", target="single") finally: category_library.CATEGORY_DIR = previous_category_dir category_extensions._EXTENSIONS_APPLIED = previous_extensions_applied @@ -4324,6 +4377,35 @@ def smoke_category_library_route() -> None: _expect(subcategory.get("slug") == "oral_sex", "exact subcategory lookup selected wrong subcategory") _expect((women_count, men_count) == (1, 1), "exact subcategory lookup changed compatible cast counts") + slash_categories = [ + {"name": "Dev", "slug": "dev", "subcategories": [{"name": "Wrong Route", "slug": "wrong_route", "items": ["wrong item"]}]}, + { + "name": "Dev / Test Wear", + "slug": "dev_test_wear", + "subcategories": [{"name": "Layered / Office", "slug": "layered_office", "items": ["structured test item"]}], + }, + ] + slash_selector = category_library.exact_subcategory_selector( + slash_categories[1], + slash_categories[1]["subcategories"][0], + ) + slash_choice = category_library.split_exact_subcategory_choice(slash_categories, slash_selector) + _expect(slash_choice is not None, "Exact selector parser did not accept slash-bearing category/subcategory names") + if slash_choice is not None: + _expect(slash_choice[0].get("slug") == "dev_test_wear", "Exact selector parser did not prefer longest category prefix") + _expect(slash_choice[1] == "Layered / Office", "Exact selector parser trimmed slash-bearing subcategory incorrectly") + slash_category, slash_subcategory, _slash_women, _slash_men = category_library.find_subcategory( + slash_categories, + "custom_random", + slash_selector, + random.Random(201), + random.Random(202), + women_count=1, + men_count=0, + ) + _expect(slash_category.get("slug") == "dev_test_wear", "Exact subcategory lookup failed slash-bearing category") + _expect(slash_subcategory.get("slug") == "layered_office", "Exact subcategory lookup failed slash-bearing subcategory") + item = category_library.compatible_entries(list(subcategory.get("items") or []), women_count, men_count)[0] scenes = category_library.configured_pool( category,