diff --git a/docs/prompt-architecture-improvement-plan.md b/docs/prompt-architecture-improvement-plan.md index 4f79bb9..4986b55 100644 --- a/docs/prompt-architecture-improvement-plan.md +++ b/docs/prompt-architecture-improvement-plan.md @@ -107,6 +107,9 @@ Already isolated: - category/cast route preset schemas, config JSON builders, choice lists, and parsers live in `category_cast_config.py`; `prompt_builder.py` keeps public delegate wrappers for existing nodes and tests. +- ethnicity/filter choices, advanced filter JSON, ethnicity-list JSON, filter + parsing, and ethnicity normalization live in `filter_config.py`; character + routes and builder filters use `prompt_builder.py` delegate wrappers. - generation profile presets, override normalization, trigger policy, and profile config parsing live in `generation_profile_config.py`; `prompt_builder.py` keeps public delegate wrappers. diff --git a/docs/prompt-pool-routing-map.md b/docs/prompt-pool-routing-map.md index d0f3e0a..7389477 100644 --- a/docs/prompt-pool-routing-map.md +++ b/docs/prompt-pool-routing-map.md @@ -70,6 +70,7 @@ Core helper ownership: | `category_library.py` | JSON category loading, subcategory normalization, named scene/expression/composition pool loading, cast compatibility filtering, exact subcategory lookup, and inheritance-based pool merging. | | `category_cast_config.py` | Category preset and cast preset schemas, category/cast config JSON builders, choice lists, and config parsers used by route nodes. | | `camera_config.py` | Camera option schema, direct/orbit/Qwen camera JSON builders, camera config parsing, plain camera directive text, and camera caption labels. | +| `filter_config.py` | Ethnicity/filter choices, advanced filter JSON, ethnicity-list JSON, filter parsing, and ethnicity normalization used by builder and character routes. | | `generation_profile_config.py` | Generation profile presets, profile option overrides, trigger policy, expression/pose/clothing config normalization, and profile config parsing. | | `seed_config.py` | Seed axis salts/aliases, seed mode choices, global/axis lock JSON builders, seed config parsing, row seed math, and deterministic axis RNG construction. | | `location_config.py` | Location/composition preset schemas, themed location packs, custom location/composition parsing, pool merge behavior, and location/composition config parsing. | diff --git a/filter_config.py b/filter_config.py new file mode 100644 index 0000000..a59e83f --- /dev/null +++ b/filter_config.py @@ -0,0 +1,265 @@ +from __future__ import annotations + +import json +from typing import Any + + +ETHNICITY_FILTER_CHOICES = [ + "any", + "european", + "mediterranean_mena", + "latina", + "east_asian", + "southeast_asian", + "south_asian", + "black_african", + "indigenous", + "mixed", + "asian", + "white_asian", + "western_european", + "french_european", + "germanic_european", + "nordic_european", + "celtic_european", + "slavic_european", + "baltic_european", + "alpine_european", + "balkan_european", + "greek_mediterranean", + "italian_mediterranean", + "iberian_mediterranean", +] +ETHNICITY_LIST_KEYS = tuple(choice for choice in ETHNICITY_FILTER_CHOICES if choice != "any") +ETHNICITY_BASE_LIST_KEYS = ( + "european", + "mediterranean_mena", + "latina", + "east_asian", + "southeast_asian", + "south_asian", + "black_african", + "indigenous", + "mixed", +) +EUROPEAN_REGIONAL_LIST_KEYS = ( + "western_european", + "french_european", + "germanic_european", + "nordic_european", + "celtic_european", + "slavic_european", + "baltic_european", + "alpine_european", + "balkan_european", +) +MEDITERRANEAN_REGIONAL_LIST_KEYS = ( + "greek_mediterranean", + "italian_mediterranean", + "iberian_mediterranean", +) +ETHNICITY_RANDOM_TOKENS = {"", "random", "auto", "global", "from_global", "default"} + + +def ethnicity_text_from_value(value: Any) -> str: + if isinstance(value, dict): + return str(value.get("ethnicity") or "").strip() + text = str(value or "").strip() + if not text: + return "" + if text.startswith("{"): + try: + raw = json.loads(text) + except json.JSONDecodeError: + return text + if isinstance(raw, dict): + return str(raw.get("ethnicity") or "").strip() + return text + + +def is_valid_ethnicity_filter(value: Any) -> bool: + text = ethnicity_text_from_value(value) + return text == "any" or text in ETHNICITY_FILTER_CHOICES or "+" in text + + +def normalize_ethnicity_filter(value: Any, default: str = "any", allow_random: bool = False) -> str: + text = ethnicity_text_from_value(value) + if text.lower() in ETHNICITY_RANDOM_TOKENS: + return "random" if allow_random else default + return text if is_valid_ethnicity_filter(text) else default + + +def build_filter_config_json( + ethnicity: str = "any", + figure: str = "curvy", + no_plus_women: bool = False, + no_black: bool = False, + include_european: bool = True, + include_mediterranean_mena: bool = True, + include_latina: bool = True, + include_east_asian: bool = True, + include_southeast_asian: bool = True, + include_south_asian: bool = True, + include_black_african: bool = True, + include_indigenous: bool = True, + include_mixed: bool = True, + include_plus_size: bool = True, +) -> str: + include_flags = { + "european": include_european, + "mediterranean_mena": include_mediterranean_mena, + "latina": include_latina, + "east_asian": include_east_asian, + "southeast_asian": include_southeast_asian, + "south_asian": include_south_asian, + "black_african": include_black_african, + "indigenous": include_indigenous, + "mixed": include_mixed, + } + selected_ethnicities = [key for key, enabled in include_flags.items() if enabled] + disabled_ethnicities = [key for key, enabled in include_flags.items() if not enabled] + enabled_ethnicities = list(selected_ethnicities) + if enabled_ethnicities: + enabled_ethnicities.extend(f"exclude_{key}" for key in disabled_ethnicities) + if 0 < len(selected_ethnicities) < len(include_flags): + ethnicity = "+".join(enabled_ethnicities) + elif not is_valid_ethnicity_filter(ethnicity): + ethnicity = "any" + return json.dumps( + { + "ethnicity": ethnicity, + "ethnicity_includes": selected_ethnicities, + "figure": figure if figure in ("curvy", "balanced", "bombshell", "random") else "curvy", + "include_plus_size": bool(include_plus_size), + "include_black_african": bool(include_black_african), + "no_plus_women": not bool(include_plus_size) or bool(no_plus_women), + "no_black": not bool(include_black_african) or bool(no_black), + }, + ensure_ascii=True, + sort_keys=True, + ) + + +def build_ethnicity_list_json( + include_european: bool = False, + include_mediterranean_mena: bool = False, + include_latina: bool = False, + include_east_asian: bool = False, + include_southeast_asian: bool = False, + include_south_asian: bool = False, + include_black_african: bool = False, + include_indigenous: bool = False, + include_mixed: bool = False, + include_asian: bool = False, + include_white_asian: bool = False, + include_western_european: bool = False, + include_french_european: bool = False, + include_germanic_european: bool = False, + include_nordic_european: bool = False, + include_celtic_european: bool = False, + include_slavic_european: bool = False, + include_baltic_european: bool = False, + include_alpine_european: bool = False, + include_balkan_european: bool = False, + include_greek_mediterranean: bool = False, + include_italian_mediterranean: bool = False, + include_iberian_mediterranean: bool = False, + strict_excludes: bool = True, +) -> dict[str, str]: + include_flags = { + "european": include_european, + "mediterranean_mena": include_mediterranean_mena, + "latina": include_latina, + "east_asian": include_east_asian, + "southeast_asian": include_southeast_asian, + "south_asian": include_south_asian, + "black_african": include_black_african, + "indigenous": include_indigenous, + "mixed": include_mixed, + "asian": include_asian, + "white_asian": include_white_asian, + "western_european": include_western_european, + "french_european": include_french_european, + "germanic_european": include_germanic_european, + "nordic_european": include_nordic_european, + "celtic_european": include_celtic_european, + "slavic_european": include_slavic_european, + "baltic_european": include_baltic_european, + "alpine_european": include_alpine_european, + "balkan_european": include_balkan_european, + "greek_mediterranean": include_greek_mediterranean, + "italian_mediterranean": include_italian_mediterranean, + "iberian_mediterranean": include_iberian_mediterranean, + } + selected = [key for key in ETHNICITY_LIST_KEYS if include_flags.get(key)] + if not selected or set(selected) == set(ETHNICITY_LIST_KEYS): + ethnicity = "any" + else: + tokens = list(selected) + if strict_excludes: + protected: set[str] = set() + if "asian" in selected: + protected.update(("east_asian", "southeast_asian", "south_asian")) + if "white_asian" in selected: + protected.update(("european", "east_asian", "southeast_asian", "south_asian", "mixed")) + if any(key in selected for key in EUROPEAN_REGIONAL_LIST_KEYS): + protected.add("european") + if any(key in selected for key in MEDITERRANEAN_REGIONAL_LIST_KEYS): + protected.add("mediterranean_mena") + if "mixed" in selected: + protected.update(ETHNICITY_BASE_LIST_KEYS) + tokens.extend( + f"exclude_{key}" + for key in ETHNICITY_BASE_LIST_KEYS + if key not in selected and key not in protected + ) + ethnicity = "+".join(tokens) + filter_config = { + "ethnicity": ethnicity, + "ethnicity_includes": selected, + } + summary = "any ethnicity" if ethnicity == "any" else "ethnicity list: " + ", ".join(selected) + return { + "ethnicity": ethnicity, + "filter_config": json.dumps(filter_config, ensure_ascii=True, sort_keys=True), + "summary": summary, + } + + +def parse_filter_config(filter_config: str | dict[str, Any] | None) -> dict[str, Any]: + defaults = { + "ethnicity": "any", + "figure": "curvy", + "no_plus_women": False, + "no_black": False, + "include_plus_size": True, + "include_black_african": True, + } + if not filter_config: + return defaults + if isinstance(filter_config, dict): + raw = filter_config + else: + text = str(filter_config).strip() + if not text.startswith("{"): + raw = {"ethnicity": text} + else: + try: + raw = json.loads(text) + except json.JSONDecodeError as exc: + raise ValueError(f"Invalid filter_config JSON: {exc}") from exc + if not isinstance(raw, dict): + raise ValueError("filter_config must be a JSON object") + parsed = {**defaults, **raw} + parsed["ethnicity"] = normalize_ethnicity_filter(parsed.get("ethnicity"), "any") + parsed["figure"] = parsed["figure"] if parsed.get("figure") in ("curvy", "balanced", "bombshell", "random") else "curvy" + parsed["include_plus_size"] = bool(parsed.get("include_plus_size")) + parsed["include_black_african"] = bool(parsed.get("include_black_african")) + parsed["no_plus_women"] = bool(parsed.get("no_plus_women")) + parsed["no_black"] = bool(parsed.get("no_black")) + return parsed + + +_ethnicity_text_from_value = ethnicity_text_from_value +_is_valid_ethnicity_filter = is_valid_ethnicity_filter +_parse_filter_config = parse_filter_config diff --git a/node_profile_filter.py b/node_profile_filter.py index 2508695..f598b99 100644 --- a/node_profile_filter.py +++ b/node_profile_filter.py @@ -3,23 +3,23 @@ from __future__ import annotations import json try: + from .filter_config import ( + build_ethnicity_list_json, + build_filter_config_json, + ) from .generation_profile_config import ( build_generation_profile_json, generation_profile_choices, ) - from .prompt_builder import ( +except ImportError: # Allows local smoke tests from the repository root. + from filter_config import ( build_ethnicity_list_json, build_filter_config_json, ) -except ImportError: # Allows local smoke tests from the repository root. from generation_profile_config import ( build_generation_profile_json, generation_profile_choices, ) - from prompt_builder import ( - build_ethnicity_list_json, - build_filter_config_json, - ) SXCP_GENERATION_PROFILE = "SXCP_GENERATION_PROFILE" diff --git a/prompt_builder.py b/prompt_builder.py index 350d9e5..90ce216 100644 --- a/prompt_builder.py +++ b/prompt_builder.py @@ -25,6 +25,7 @@ try: ) from . import camera_config as camera_policy from . import category_cast_config as category_cast_policy + from . import filter_config as filter_policy from . import generate_prompt_batches as g from . import generation_profile_config as generation_profile_policy from . import location_config as location_policy @@ -65,6 +66,7 @@ except ImportError: # Allows local smoke tests with `python -c`. ) import camera_config as camera_policy import category_cast_config as category_cast_policy + import filter_config as filter_policy import generate_prompt_batches as g import generation_profile_config as generation_profile_policy import location_config as location_policy @@ -107,60 +109,11 @@ SEED_AXIS_ALIASES = seed_policy.SEED_AXIS_ALIASES SEED_LOCK_AXES = seed_policy.SEED_LOCK_AXES SEED_MODE_CHOICES = seed_policy.SEED_MODE_CHOICES -ETHNICITY_FILTER_CHOICES = [ - "any", - "european", - "mediterranean_mena", - "latina", - "east_asian", - "southeast_asian", - "south_asian", - "black_african", - "indigenous", - "mixed", - "asian", - "white_asian", - "western_european", - "french_european", - "germanic_european", - "nordic_european", - "celtic_european", - "slavic_european", - "baltic_european", - "alpine_european", - "balkan_european", - "greek_mediterranean", - "italian_mediterranean", - "iberian_mediterranean", -] -ETHNICITY_LIST_KEYS = tuple(choice for choice in ETHNICITY_FILTER_CHOICES if choice != "any") -ETHNICITY_BASE_LIST_KEYS = ( - "european", - "mediterranean_mena", - "latina", - "east_asian", - "southeast_asian", - "south_asian", - "black_african", - "indigenous", - "mixed", -) -EUROPEAN_REGIONAL_LIST_KEYS = ( - "western_european", - "french_european", - "germanic_european", - "nordic_european", - "celtic_european", - "slavic_european", - "baltic_european", - "alpine_european", - "balkan_european", -) -MEDITERRANEAN_REGIONAL_LIST_KEYS = ( - "greek_mediterranean", - "italian_mediterranean", - "iberian_mediterranean", -) +ETHNICITY_FILTER_CHOICES = filter_policy.ETHNICITY_FILTER_CHOICES +ETHNICITY_LIST_KEYS = filter_policy.ETHNICITY_LIST_KEYS +ETHNICITY_BASE_LIST_KEYS = filter_policy.ETHNICITY_BASE_LIST_KEYS +EUROPEAN_REGIONAL_LIST_KEYS = filter_policy.EUROPEAN_REGIONAL_LIST_KEYS +MEDITERRANEAN_REGIONAL_LIST_KEYS = filter_policy.MEDITERRANEAN_REGIONAL_LIST_KEYS CHARACTER_LABEL_CHOICES = [ "auto_chain", @@ -1114,38 +1067,21 @@ def build_filter_config_json( include_mixed: bool = True, include_plus_size: bool = True, ) -> str: - include_flags = { - "european": include_european, - "mediterranean_mena": include_mediterranean_mena, - "latina": include_latina, - "east_asian": include_east_asian, - "southeast_asian": include_southeast_asian, - "south_asian": include_south_asian, - "black_african": include_black_african, - "indigenous": include_indigenous, - "mixed": include_mixed, - } - selected_ethnicities = [key for key, enabled in include_flags.items() if enabled] - disabled_ethnicities = [key for key, enabled in include_flags.items() if not enabled] - enabled_ethnicities = list(selected_ethnicities) - if enabled_ethnicities: - enabled_ethnicities.extend(f"exclude_{key}" for key in disabled_ethnicities) - if 0 < len(selected_ethnicities) < len(include_flags): - ethnicity = "+".join(enabled_ethnicities) - elif not _is_valid_ethnicity_filter(ethnicity): - ethnicity = "any" - return json.dumps( - { - "ethnicity": ethnicity, - "ethnicity_includes": selected_ethnicities, - "figure": figure if figure in ("curvy", "balanced", "bombshell", "random") else "curvy", - "include_plus_size": bool(include_plus_size), - "include_black_african": bool(include_black_african), - "no_plus_women": not bool(include_plus_size) or bool(no_plus_women), - "no_black": not bool(include_black_african) or bool(no_black), - }, - ensure_ascii=True, - sort_keys=True, + return filter_policy.build_filter_config_json( + ethnicity=ethnicity, + figure=figure, + no_plus_women=no_plus_women, + no_black=no_black, + include_european=include_european, + include_mediterranean_mena=include_mediterranean_mena, + include_latina=include_latina, + include_east_asian=include_east_asian, + include_southeast_asian=include_southeast_asian, + include_south_asian=include_south_asian, + include_black_african=include_black_african, + include_indigenous=include_indigenous, + include_mixed=include_mixed, + include_plus_size=include_plus_size, ) @@ -1260,31 +1196,15 @@ def build_thematic_location_json( def _ethnicity_text_from_value(value: Any) -> str: - if isinstance(value, dict): - return str(value.get("ethnicity") or "").strip() - text = str(value or "").strip() - if not text: - return "" - if text.startswith("{"): - try: - raw = json.loads(text) - except json.JSONDecodeError: - return text - if isinstance(raw, dict): - return str(raw.get("ethnicity") or "").strip() - return text + return filter_policy.ethnicity_text_from_value(value) def _is_valid_ethnicity_filter(value: Any) -> bool: - text = _ethnicity_text_from_value(value) - return text == "any" or text in ETHNICITY_FILTER_CHOICES or "+" in text + return filter_policy.is_valid_ethnicity_filter(value) def normalize_ethnicity_filter(value: Any, default: str = "any", allow_random: bool = False) -> str: - text = _ethnicity_text_from_value(value) - if text.lower() in CHARACTER_RANDOM_TOKENS: - return "random" if allow_random else default - return text if _is_valid_ethnicity_filter(text) else default + return filter_policy.normalize_ethnicity_filter(value, default, allow_random) def build_ethnicity_list_json( @@ -1313,98 +1233,36 @@ def build_ethnicity_list_json( include_iberian_mediterranean: bool = False, strict_excludes: bool = True, ) -> dict[str, str]: - include_flags = { - "european": include_european, - "mediterranean_mena": include_mediterranean_mena, - "latina": include_latina, - "east_asian": include_east_asian, - "southeast_asian": include_southeast_asian, - "south_asian": include_south_asian, - "black_african": include_black_african, - "indigenous": include_indigenous, - "mixed": include_mixed, - "asian": include_asian, - "white_asian": include_white_asian, - "western_european": include_western_european, - "french_european": include_french_european, - "germanic_european": include_germanic_european, - "nordic_european": include_nordic_european, - "celtic_european": include_celtic_european, - "slavic_european": include_slavic_european, - "baltic_european": include_baltic_european, - "alpine_european": include_alpine_european, - "balkan_european": include_balkan_european, - "greek_mediterranean": include_greek_mediterranean, - "italian_mediterranean": include_italian_mediterranean, - "iberian_mediterranean": include_iberian_mediterranean, - } - selected = [key for key in ETHNICITY_LIST_KEYS if include_flags.get(key)] - if not selected or set(selected) == set(ETHNICITY_LIST_KEYS): - ethnicity = "any" - else: - tokens = list(selected) - if strict_excludes: - protected: set[str] = set() - if "asian" in selected: - protected.update(("east_asian", "southeast_asian", "south_asian")) - if "white_asian" in selected: - protected.update(("european", "east_asian", "southeast_asian", "south_asian", "mixed")) - if any(key in selected for key in EUROPEAN_REGIONAL_LIST_KEYS): - protected.add("european") - if any(key in selected for key in MEDITERRANEAN_REGIONAL_LIST_KEYS): - protected.add("mediterranean_mena") - if "mixed" in selected: - protected.update(ETHNICITY_BASE_LIST_KEYS) - tokens.extend( - f"exclude_{key}" - for key in ETHNICITY_BASE_LIST_KEYS - if key not in selected and key not in protected - ) - ethnicity = "+".join(tokens) - filter_config = { - "ethnicity": ethnicity, - "ethnicity_includes": selected, - } - summary = "any ethnicity" if ethnicity == "any" else "ethnicity list: " + ", ".join(selected) - return { - "ethnicity": ethnicity, - "filter_config": json.dumps(filter_config, ensure_ascii=True, sort_keys=True), - "summary": summary, - } + return filter_policy.build_ethnicity_list_json( + include_european=include_european, + include_mediterranean_mena=include_mediterranean_mena, + include_latina=include_latina, + include_east_asian=include_east_asian, + include_southeast_asian=include_southeast_asian, + include_south_asian=include_south_asian, + include_black_african=include_black_african, + include_indigenous=include_indigenous, + include_mixed=include_mixed, + include_asian=include_asian, + include_white_asian=include_white_asian, + include_western_european=include_western_european, + include_french_european=include_french_european, + include_germanic_european=include_germanic_european, + include_nordic_european=include_nordic_european, + include_celtic_european=include_celtic_european, + include_slavic_european=include_slavic_european, + include_baltic_european=include_baltic_european, + include_alpine_european=include_alpine_european, + include_balkan_european=include_balkan_european, + include_greek_mediterranean=include_greek_mediterranean, + include_italian_mediterranean=include_italian_mediterranean, + include_iberian_mediterranean=include_iberian_mediterranean, + strict_excludes=strict_excludes, + ) def _parse_filter_config(filter_config: str | dict[str, Any] | None) -> dict[str, Any]: - defaults = { - "ethnicity": "any", - "figure": "curvy", - "no_plus_women": False, - "no_black": False, - "include_plus_size": True, - "include_black_african": True, - } - if not filter_config: - return defaults - if isinstance(filter_config, dict): - raw = filter_config - else: - text = str(filter_config).strip() - if not text.startswith("{"): - raw = {"ethnicity": text} - else: - try: - raw = json.loads(text) - except json.JSONDecodeError as exc: - raise ValueError(f"Invalid filter_config JSON: {exc}") from exc - if not isinstance(raw, dict): - raise ValueError("filter_config must be a JSON object") - parsed = {**defaults, **raw} - parsed["ethnicity"] = normalize_ethnicity_filter(parsed.get("ethnicity"), "any") - parsed["figure"] = parsed["figure"] if parsed.get("figure") in ("curvy", "balanced", "bombshell", "random") else "curvy" - parsed["include_plus_size"] = bool(parsed.get("include_plus_size")) - parsed["include_black_african"] = bool(parsed.get("include_black_african")) - parsed["no_plus_women"] = bool(parsed.get("no_plus_women")) - parsed["no_black"] = bool(parsed.get("no_black")) - return parsed + return filter_policy.parse_filter_config(filter_config) def _normalize_hardcore_position_family(value: Any, default: str = "any") -> str: diff --git a/tools/prompt_smoke.py b/tools/prompt_smoke.py index 088f18a..72e0632 100644 --- a/tools/prompt_smoke.py +++ b/tools/prompt_smoke.py @@ -26,6 +26,7 @@ if str(ROOT) not in sys.path: import caption_naturalizer # noqa: E402 import category_cast_config # noqa: E402 import category_library # noqa: E402 +import filter_config # noqa: E402 import __init__ as sxcp_nodes # noqa: E402 import generation_profile_config # noqa: E402 import krea_formatter # noqa: E402 @@ -620,6 +621,46 @@ def smoke_generation_profile_config_policy() -> None: _expect(fallback.get("trigger") == "sxcpinup_coloredpencil", "Generation profile parser lost default trigger") +def smoke_filter_config_policy() -> None: + _expect(pb.ETHNICITY_FILTER_CHOICES is filter_config.ETHNICITY_FILTER_CHOICES, "Prompt builder ethnicity choices are not delegated") + _expect("french_european" in filter_config.ETHNICITY_LIST_KEYS, "Ethnicity list keys lost regional choices") + + advanced = json.loads( + pb.build_filter_config_json( + include_european=True, + include_mediterranean_mena=False, + include_latina=False, + include_east_asian=False, + include_southeast_asian=False, + include_south_asian=False, + include_black_african=True, + include_indigenous=False, + include_mixed=False, + include_plus_size=False, + figure="bad", + ) + ) + _expect(advanced.get("ethnicity_includes") == ["european", "black_african"], "Advanced filter selected ethnicity list changed") + _expect("exclude_latina" in advanced.get("ethnicity", ""), "Advanced filter ethnicity excludes changed") + _expect(advanced.get("figure") == "curvy", "Advanced filter invalid figure fallback changed") + _expect(advanced.get("no_plus_women") is True, "Advanced filter plus-size exclusion changed") + + ethnicity_list = pb.build_ethnicity_list_json(include_french_european=True, include_asian=True, strict_excludes=True) + _expect("french_european" in ethnicity_list["ethnicity"], "Ethnicity list lost regional include") + _expect("asian" in ethnicity_list["ethnicity"], "Ethnicity list lost umbrella Asian include") + _expect("exclude_european" not in ethnicity_list["ethnicity"], "Ethnicity list should protect European when regional Europe is selected") + _expect("exclude_east_asian" not in ethnicity_list["ethnicity"], "Ethnicity list should protect East Asian when Asian is selected") + _expect("filter_config" in ethnicity_list, "Ethnicity list lost filter_config output") + + parsed_text = pb._parse_filter_config("french_european") + _expect(parsed_text.get("ethnicity") == "french_european", "Filter parser text shortcut changed") + parsed_bad = filter_config.parse_filter_config({"ethnicity": "bad", "figure": "bad"}) + _expect(parsed_bad.get("ethnicity") == "any", "Filter parser invalid ethnicity fallback changed") + _expect(parsed_bad.get("figure") == "curvy", "Filter parser invalid figure fallback changed") + _expect(pb.normalize_ethnicity_filter("random", "any", allow_random=True) == "random", "Ethnicity random normalization changed") + _expect(pb.normalize_ethnicity_filter("random", "any", allow_random=False) == "any", "Ethnicity default normalization changed") + + def smoke_category_library_route() -> None: categories = category_library.load_category_library() _expect(len(categories) >= 3, "category library should load JSON categories") @@ -2529,6 +2570,7 @@ SMOKE_CASES: list[tuple[str, Callable[[], None]]] = [ ("location_config_policy", smoke_location_config_policy), ("category_cast_config_policy", smoke_category_cast_config_policy), ("generation_profile_config_policy", smoke_generation_profile_config_policy), + ("filter_config_policy", smoke_filter_config_policy), ("category_library_route", smoke_category_library_route), ("hardcore_category_routes", smoke_hardcore_category_routes), ("krea_close_foreplay_route", smoke_krea_close_foreplay_route),