From d4d3be57896d543f54ab4e3ad34fdfb0697d5204 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Sat, 27 Jun 2026 03:02:23 +0200 Subject: [PATCH] Move hardcore position filtering policy --- docs/prompt-architecture-improvement-plan.md | 4 +- docs/prompt-pool-routing-map.md | 15 +- hardcore_position_config.py | 263 ++++++++++++++++++- prompt_builder.py | 244 +---------------- tools/prompt_smoke.py | 63 +++++ 5 files changed, 343 insertions(+), 246 deletions(-) diff --git a/docs/prompt-architecture-improvement-plan.md b/docs/prompt-architecture-improvement-plan.md index 4fd81f5..1c07ede 100644 --- a/docs/prompt-architecture-improvement-plan.md +++ b/docs/prompt-architecture-improvement-plan.md @@ -150,8 +150,8 @@ Already isolated: to rows. - hardcore position/action-filter choices, selected-position normalization, config JSON builders/parsers, focus-policy toggles, subcategory allow-list - policy, and position-key detection live in `hardcore_position_config.py`; - `prompt_builder.py` still applies the config to category rows. + policy, position-key detection, category filtering, and item-template/axis + filtering live in `hardcore_position_config.py`. - hardcore configured-cast role graph generation lives in `hardcore_role_graphs.py`; `prompt_builder.py` selects item/axis metadata and then asks that module for the source role graph. diff --git a/docs/prompt-pool-routing-map.md b/docs/prompt-pool-routing-map.md index 363b2d7..6bc035e 100644 --- a/docs/prompt-pool-routing-map.md +++ b/docs/prompt-pool-routing-map.md @@ -77,7 +77,7 @@ Core helper ownership: | `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. | -| `hardcore_position_config.py` | Hardcore position/action-filter choices, selected-position normalization, config JSON builders/parsers, focus-policy toggles, subcategory allow-list policy, and position-key detection. | +| `hardcore_position_config.py` | Hardcore position/action-filter choices, selected-position normalization, config JSON builders/parsers, focus-policy toggles, subcategory allow-list policy, position-key detection, and category/template/axis filtering. | | `pair_options.py` | Insta/OF option schema/defaults, softcore category/outfit/pose pools, partner outfit pools, clothing-continuity labels, negatives, and hardcore cast count policy. | | `pair_rows.py` | Insta/OF soft/hard row creation, softcore expression override resolution, Woman A slot context application, soft outfit/pose overrides, and POV row fields. | | `pair_camera.py` | Insta/OF soft/hard camera route resolution, same-as-softcore camera mode, camera-detail override, camera-aware composition mutation, POV camera suppression, and synchronized row/root camera metadata. | @@ -126,7 +126,7 @@ These recipes identify the intended road before editing prompt text. | Request | Preferred node route | Critical settings | If wrong, inspect | | --- | --- | --- | --- | | Keep character/location but change only sexual pose | `Global Seed` or fixed seed config -> builder/pair | Keep `person_seed` and `scene_seed` fixed; change `pose_seed` and usually `role_seed`; for hardcore categories check `content_seed_axis` | `sexual_poses.json`, `hardcore_position_config`, `krea_actions.hardcore_action_sentence` | -| Generate a specific hardcore oral/blowjob scene | `Hardcore Position Pool` -> `Hardcore Action Filter` -> `Insta/OF Prompt Pair` or `Prompt Builder` | Use `focus=oral_only` or disable non-oral families; keep `allow_oral=true`; constrain position pool to kneeling/standing/oral variants when needed | `sexual_poses.json` oral subcategory/templates, `_apply_hardcore_position_config_to_subcategory`, `krea_actions.hardcore_action_sentence` | +| Generate a specific hardcore oral/blowjob scene | `Hardcore Position Pool` -> `Hardcore Action Filter` -> `Insta/OF Prompt Pair` or `Prompt Builder` | Use `focus=oral_only` or disable non-oral families; keep `allow_oral=true`; constrain position pool to kneeling/standing/oral variants when needed | `sexual_poses.json` oral subcategory/templates, `hardcore_position_config.apply_hardcore_position_config_to_subcategory`, `krea_actions.hardcore_action_sentence` | | Generate POV oral or POV penetration | `Man Slot` with POV presence -> `character_cast` -> pair/builder -> Krea2 formatter | POV man must be in the cast; use metadata into Krea2; normal camera directive is suppressed by POV | `krea_pov_actions.py`, `krea_pov.py`, `krea_cast.cast_prose` omit-label handling | | Generate porn-scene interaction beats | `Hardcore Position Pool` -> `Hardcore Action Filter` -> pair/builder | Use `focus=interaction_only` for kissing/body worship/transitions/guidance/camera/watching/aftercare, or `focus=manual_only` for fingering/clit/manual stimulation; constrain keys such as `camera_showing`, `wrist_pinning`, `fingering`, `aftercare` | `sexual_poses.json` interaction/manual subcategories, `_role_graph`, `krea_action_context.is_foreplay_text` / `krea_actions.hardcore_action_sentence` | | Same woman, same room, softcore and hardcore outputs | `Character Slot/Profile` -> `Insta/OF Options` -> `Insta/OF Prompt Pair` | `continuity=same_creator_same_room`; set `softcore_cast` as needed; use pair metadata into formatter | `build_insta_of_pair`, `softcore_row`, `hardcore_row`, pair metadata fields | @@ -325,9 +325,8 @@ Edit targets: `foreplay_teasing`, `manual_stimulation`, `body_worship_touching`, `clothing_position_transitions`, `dominant_guidance`, `camera_performance`, `group_coordination`, and `aftercare_cleanup`. -- Position filtering UI/config: builders and parsers live in - `hardcore_position_config.py`; `prompt_builder._apply_hardcore_position_config_to_subcategory` - applies the config to category rows. +- Position filtering UI/config and category/template/axis filter policy live in + `hardcore_position_config.py`. - Krea2 action rewrite orchestration: `krea_formatter.py`. - Krea2 non-POV position anchors/arrangements: `krea_action_positions.py`. - Krea2 non-climax item/detail cleanup: `krea_action_details.py`. @@ -517,8 +516,8 @@ plain prompt text. When debugging, inspect these fields before editing pools. flowchart TD A[Hardcore Position Pool] --> C[hardcore_position_config] B[Hardcore Action Filter] --> C - C --> D[_filter_hardcore_categories_for_position] - C --> E[_apply_hardcore_position_config_to_subcategory] + C --> D[hardcore_position_config.filter_hardcore_categories_for_position] + C --> E[hardcore_position_config.apply_hardcore_position_config_to_subcategory] E --> F[item_templates / axes in sexual_poses.json] F --> G[role_graph + item + axis_values] G --> H[Krea2 action sentence and POV rewrite] @@ -831,7 +830,7 @@ pair metadata through the core Python APIs, then verifies: | Repeated desk/anchor in POV foreground | Coworking direction/distance/elevation helpers. | | Wrong expression intensity | Character slot expression settings, `_expression_entries_for_intensity`, expression pools. | | Expression appears when disabled | `_disable_row_expression`, formatter expression extraction. | -| Same hardcore action repeats | Hardcore filter config, `sexual_poses.json` weights, `_apply_hardcore_position_config_to_subcategory`. | +| Same hardcore action repeats | Hardcore filter config, `sexual_poses.json` weights, `hardcore_position_config.apply_hardcore_position_config_to_subcategory`. | | Hardcore interaction beat falls back to penetration/oral | `sexual_poses.json` interaction subcategory, `_role_graph`, and `krea_action_context.is_foreplay_text` / `krea_action_positions.hardcore_pose_anchor`. | | Raw hardcore prompt position is vague | `sexual_poses.json` item templates and role graph templates. | | Krea2 hardcore prompt position is vague | `krea_actions.hardcore_action_sentence` or `krea_pov_actions.py`. | diff --git a/hardcore_position_config.py b/hardcore_position_config.py index e5bcf69..7c6fd00 100644 --- a/hardcore_position_config.py +++ b/hardcore_position_config.py @@ -2,7 +2,8 @@ from __future__ import annotations import json import re -from typing import Any +from string import Formatter +from typing import Any, Callable HARDCORE_POSITION_FAMILY_CHOICES = [ @@ -226,6 +227,19 @@ def _list_from(value: Any) -> list[Any]: return [value] +def _entry_text(item: Any) -> str: + if isinstance(item, dict): + return str( + item.get("template") + or item.get("prompt") + or item.get("text") + or item.get("description") + or item.get("name") + or "" + ).strip() + return str(item).strip() + + def hardcore_position_family_choices() -> list[str]: return list(HARDCORE_POSITION_FAMILY_CHOICES) @@ -486,6 +500,253 @@ def hardcore_allowed_subcategory_slugs(config: dict[str, Any]) -> set[str]: return allowed or set(HARDCORE_POSITION_FAMILY_SUBCATEGORIES["any"]) +def is_hardcore_sexual_category(category: dict[str, Any]) -> bool: + return ( + str(category.get("slug") or "").strip() == "hardcore_sexual_poses" + or str(category.get("name") or "").strip().lower() == "hardcore sexual poses" + ) + + +def hardcore_text_blocked_by_action(text: str, axis_name: str, config: dict[str, Any]) -> bool: + text = str(text or "").lower() + axis_name = str(axis_name or "").lower() + if not config.get("allow_toys", True) and any(term in text for term in ("toy", "dildo", "strap-on", "strap on")): + return True + if not config.get("allow_double", True) and ( + axis_name == "double_act" + or any(term in text for term in ("double penetration", "double-penetration", "front-and-back", "front and back", "second penetration", "both sides", "two partners penetrating", "multiple penetrations")) + ): + return True + if not config.get("allow_anal", True) and ( + axis_name == "anal_act" + or any(term in text for term in (" anal", "anal sex", "anal penetration", "anus", "rear-entry anal", "penis entering ass", "thrusts into her ass", "thrusts into his ass")) + ): + return True + if not config.get("allow_oral", True) and ( + axis_name in ("oral_act", "oral_detail") + or any(term in text for term in ("oral sex", "mouth on genitals", "mouth on pussy", "blowjob", "cunnilingus", "tongue on pussy", "deepthroat", "fellatio")) + ): + return True + if not config.get("allow_outercourse", True) and ( + axis_name in ("outer_act", "contact_detail", "texture_detail") + or any(term in text for term in ("boobjob", "titjob", "breast sex", "breast-sex", "testicle", "balls", "penis licking", "penis-licking", "footjob", "soles", "toes")) + ): + return True + if not config.get("allow_penetration", True) and ( + axis_name in ("penetration_act", "penetration_detail", "anal_act", "double_act", "thrust_detail") + or any(term in text for term in ("penetration", "penetrative", "thrust", "penis entering", "vaginal sex", "anal sex")) + ): + return True + if not config.get("allow_foreplay", True) and ( + axis_name in ("tease_act", "touch_detail", "clothing_detail", "foreplay_detail", "face_detail", "body_contact", "mood_detail") + or any( + term in text + for term in ( + "kiss", + "kissing", + "mouth-to-mouth", + "caress", + "caressing", + "stroking skin", + "hands roaming", + "touching breasts", + "cupping breasts", + "hand on the cheek", + "fingers under the chin", + "undressing", + "removing clothing", + "removing clothes", + "pulling clothing", + "sliding straps", + "unbuttoning", + ) + ) + ): + return True + if not config.get("allow_interaction", True) and ( + axis_name + in ( + "tease_act", + "touch_detail", + "clothing_detail", + "foreplay_detail", + "face_detail", + "body_contact", + "mood_detail", + "worship_act", + "transition_act", + "control_act", + "performance_act", + "coordination_act", + "aftercare_act", + "cleanup_detail", + ) + or any( + term in text + for term in ( + "kiss", + "kissing", + "caress", + "body worship", + "nipple", + "ass grab", + "thigh", + "hair holding", + "wrists", + "dirty talk", + "whispering", + "undressing", + "position transition", + "guided", + "camera", + "watching", + "aftercare", + "cleanup", + "wiping", + ) + ) + ): + return True + if not config.get("allow_manual", True) and ( + axis_name in ("manual_act", "manual_detail") + or any( + term in text + for term in ( + "fingering", + "fingers inside", + "clit", + "clitoris", + "manual stimulation", + "mutual masturbation", + "masturbating together", + "fingers on pussy", + "fingers on clit", + ) + ) + ): + return True + if not config.get("allow_climax", True) and ( + axis_name in ("climax_act", "climax_hint", "climax_detail", "fluid_detail", "fluid_location") + or any(term in text for term in ("climax", "cum", "semen", "ejaculat", "creampie", "post-orgasm", "post-penetration")) + ): + return True + return False + + +def hardcore_position_entry_matches(entry: Any, config: dict[str, Any]) -> bool: + positions = config.get("positions") or [] + if not positions: + return True + text = _entry_text(entry).lower() + for position in positions: + if any(term in text for term in HARDCORE_POSITION_KEY_MATCHES.get(position, ())): + return True + return False + + +def hardcore_position_entry_conflicts(entry: Any, config: dict[str, Any]) -> bool: + selected = set(config.get("positions") or []) + if not selected: + return False + text = _entry_text(entry).lower() + matched = { + position + for position, terms in HARDCORE_POSITION_KEY_MATCHES.items() + if any(term in text for term in terms) + } + return bool(matched) and not bool(matched & selected) + + +def hardcore_subcategory_supports_positions(subcategory: dict[str, Any], config: dict[str, Any]) -> bool: + if not hardcore_position_template_required(config): + return True + axes = subcategory.get("item_axes") + if not isinstance(axes, dict): + return True + for axis_name, values in axes.items(): + if str(axis_name) in HARDCORE_POSITION_AXIS_KEYS and any( + hardcore_position_entry_matches(value, config) + for value in _list_from(values) + ): + return True + return False + + +def filter_hardcore_axis(axis_name: str, values: list[Any], config: dict[str, Any]) -> list[Any]: + if not hardcore_position_config_active(config): + return values + filtered = [ + value + for value in values + if not hardcore_text_blocked_by_action(_entry_text(value), axis_name, config) + and not (axis_name not in HARDCORE_POSITION_AXIS_KEYS and hardcore_position_entry_conflicts(value, config)) + and (axis_name not in HARDCORE_POSITION_AXIS_KEYS or hardcore_position_entry_matches(value, config)) + ] + return filtered or values + + +def filter_hardcore_templates(templates: list[Any], config: dict[str, Any]) -> list[Any]: + if not hardcore_position_config_active(config): + return templates + filtered: list[Any] = [] + for template in templates: + text = _entry_text(template) + fields = {key for _, key, _, _ in Formatter().parse(text) if key} + blocked = hardcore_position_template_required(config) and not bool(fields & HARDCORE_POSITION_AXIS_KEYS) + blocked = blocked or any(hardcore_text_blocked_by_action(text, field, config) for field in fields | {""}) + if not blocked: + filtered.append(template) + return filtered or templates + + +def apply_hardcore_position_config_to_subcategory( + subcategory: dict[str, Any], + config: dict[str, Any], +) -> dict[str, Any]: + if not hardcore_position_config_active(config): + return subcategory + subcategory_copy = dict(subcategory) + if "item_templates" in subcategory_copy: + subcategory_copy["item_templates"] = filter_hardcore_templates(_list_from(subcategory_copy["item_templates"]), config) + raw_axes = subcategory_copy.get("item_axes") + if isinstance(raw_axes, dict): + axes = {} + for axis_name, values in raw_axes.items(): + axes[axis_name] = filter_hardcore_axis(str(axis_name), _list_from(values), config) + subcategory_copy["item_axes"] = axes + subcategory_copy["hardcore_position_config"] = config + return subcategory_copy + + +def filter_hardcore_categories_for_position( + categories: list[dict[str, Any]], + config: dict[str, Any], + women_count: int, + men_count: int, + compatible_entry: Callable[[dict[str, Any], int, int], bool], +) -> list[dict[str, Any]]: + if not hardcore_position_config_active(config): + return categories + allowed = hardcore_allowed_subcategory_slugs(config) + filtered_categories: list[dict[str, Any]] = [] + for category in categories: + if not is_hardcore_sexual_category(category): + filtered_categories.append(category) + continue + category_copy = dict(category) + subcategories = [ + subcategory + for subcategory in category.get("subcategories", []) + if str(subcategory.get("slug") or "") in allowed + and compatible_entry(subcategory, women_count, men_count) + and hardcore_subcategory_supports_positions(subcategory, config) + ] + if subcategories: + category_copy["subcategories"] = subcategories + filtered_categories.append(category_copy) + return filtered_categories + + def hardcore_source_position_family(subcategory: dict[str, Any], config: dict[str, Any] | None = None) -> str: slug = str(subcategory.get("slug") or subcategory.get("name") or "").strip().lower() family = HARDCORE_SOURCE_FAMILY_BY_SUBCATEGORY.get(slug, "") diff --git a/prompt_builder.py b/prompt_builder.py index 8d213d2..7870789 100644 --- a/prompt_builder.py +++ b/prompt_builder.py @@ -135,8 +135,6 @@ HARDCORE_POSITION_FAMILY_CHOICES = hardcore_position_policy.HARDCORE_POSITION_FA HARDCORE_POSITION_FOCUS_CHOICES = hardcore_position_policy.HARDCORE_POSITION_FOCUS_CHOICES HARDCORE_POSITION_KEY_CHOICES = hardcore_position_policy.HARDCORE_POSITION_KEY_CHOICES HARDCORE_POSITION_FAMILY_SUBCATEGORIES = hardcore_position_policy.HARDCORE_POSITION_FAMILY_SUBCATEGORIES -HARDCORE_POSITION_KEY_MATCHES = hardcore_position_policy.HARDCORE_POSITION_KEY_MATCHES -HARDCORE_POSITION_AXIS_KEYS = hardcore_position_policy.HARDCORE_POSITION_AXIS_KEYS HARDCORE_SOURCE_FAMILY_BY_SUBCATEGORY = hardcore_position_policy.HARDCORE_SOURCE_FAMILY_BY_SUBCATEGORY @@ -997,16 +995,8 @@ def _hardcore_position_config_active(config: dict[str, Any]) -> bool: return hardcore_position_policy.hardcore_position_config_active(config) -def _hardcore_position_template_required(config: dict[str, Any]) -> bool: - return hardcore_position_policy.hardcore_position_template_required(config) - - def _is_hardcore_sexual_category(category: dict[str, Any]) -> bool: - return str(category.get("slug") or "").strip() == "hardcore_sexual_poses" or str(category.get("name") or "").strip().lower() == "hardcore sexual poses" - - -def _hardcore_allowed_subcategory_slugs(config: dict[str, Any]) -> set[str]: - return hardcore_position_policy.hardcore_allowed_subcategory_slugs(config) + return hardcore_position_policy.is_hardcore_sexual_category(category) def _filter_hardcore_categories_for_position( @@ -1015,236 +1005,20 @@ def _filter_hardcore_categories_for_position( women_count: int, men_count: int, ) -> list[dict[str, Any]]: - if not _hardcore_position_config_active(config): - return categories - allowed = _hardcore_allowed_subcategory_slugs(config) - filtered_categories: list[dict[str, Any]] = [] - for category in categories: - if not _is_hardcore_sexual_category(category): - filtered_categories.append(category) - continue - category_copy = dict(category) - subcategories = [ - subcategory - for subcategory in category.get("subcategories", []) - if str(subcategory.get("slug") or "") in allowed and _compatible_entry(subcategory, women_count, men_count) - and _hardcore_subcategory_supports_positions(subcategory, config) - ] - if subcategories: - category_copy["subcategories"] = subcategories - filtered_categories.append(category_copy) - return filtered_categories - - -def _hardcore_text_blocked_by_action(text: str, axis_name: str, config: dict[str, Any]) -> bool: - text = str(text or "").lower() - axis_name = str(axis_name or "").lower() - if not config.get("allow_toys", True) and any(term in text for term in ("toy", "dildo", "strap-on", "strap on")): - return True - if not config.get("allow_double", True) and ( - axis_name == "double_act" - or any(term in text for term in ("double penetration", "double-penetration", "front-and-back", "front and back", "second penetration", "both sides", "two partners penetrating", "multiple penetrations")) - ): - return True - if not config.get("allow_anal", True) and ( - axis_name == "anal_act" - or any(term in text for term in (" anal", "anal sex", "anal penetration", "anus", "rear-entry anal", "penis entering ass", "thrusts into her ass", "thrusts into his ass")) - ): - return True - if not config.get("allow_oral", True) and ( - axis_name in ("oral_act", "oral_detail") - or any(term in text for term in ("oral sex", "mouth on genitals", "mouth on pussy", "blowjob", "cunnilingus", "tongue on pussy", "deepthroat", "fellatio")) - ): - return True - if not config.get("allow_outercourse", True) and ( - axis_name in ("outer_act", "contact_detail", "texture_detail") - or any(term in text for term in ("boobjob", "titjob", "breast sex", "breast-sex", "testicle", "balls", "penis licking", "penis-licking", "footjob", "soles", "toes")) - ): - return True - if not config.get("allow_penetration", True) and ( - axis_name in ("penetration_act", "penetration_detail", "anal_act", "double_act", "thrust_detail") - or any(term in text for term in ("penetration", "penetrative", "thrust", "penis entering", "vaginal sex", "anal sex")) - ): - return True - if not config.get("allow_foreplay", True) and ( - axis_name in ("tease_act", "touch_detail", "clothing_detail", "foreplay_detail", "face_detail", "body_contact", "mood_detail") - or any( - term in text - for term in ( - "kiss", - "kissing", - "mouth-to-mouth", - "caress", - "caressing", - "stroking skin", - "hands roaming", - "touching breasts", - "cupping breasts", - "hand on the cheek", - "fingers under the chin", - "undressing", - "removing clothing", - "removing clothes", - "pulling clothing", - "sliding straps", - "unbuttoning", - ) - ) - ): - return True - if not config.get("allow_interaction", True) and ( - axis_name - in ( - "tease_act", - "touch_detail", - "clothing_detail", - "foreplay_detail", - "face_detail", - "body_contact", - "mood_detail", - "worship_act", - "transition_act", - "control_act", - "performance_act", - "coordination_act", - "aftercare_act", - "cleanup_detail", - ) - or any( - term in text - for term in ( - "kiss", - "kissing", - "caress", - "body worship", - "nipple", - "ass grab", - "thigh", - "hair holding", - "wrists", - "dirty talk", - "whispering", - "undressing", - "position transition", - "guided", - "camera", - "watching", - "aftercare", - "cleanup", - "wiping", - ) - ) - ): - return True - if not config.get("allow_manual", True) and ( - axis_name in ("manual_act", "manual_detail") - or any( - term in text - for term in ( - "fingering", - "fingers inside", - "clit", - "clitoris", - "manual stimulation", - "mutual masturbation", - "masturbating together", - "fingers on pussy", - "fingers on clit", - ) - ) - ): - return True - if not config.get("allow_climax", True) and ( - axis_name in ("climax_act", "climax_hint", "climax_detail", "fluid_detail", "fluid_location") - or any(term in text for term in ("climax", "cum", "semen", "ejaculat", "creampie", "post-orgasm", "post-penetration")) - ): - return True - return False - - -def _hardcore_position_entry_matches(entry: Any, config: dict[str, Any]) -> bool: - positions = config.get("positions") or [] - if not positions: - return True - text = _entry_text(entry).lower() - for position in positions: - if any(term in text for term in HARDCORE_POSITION_KEY_MATCHES.get(position, ())): - return True - return False - - -def _hardcore_position_entry_conflicts(entry: Any, config: dict[str, Any]) -> bool: - selected = set(config.get("positions") or []) - if not selected: - return False - text = _entry_text(entry).lower() - matched = { - position - for position, terms in HARDCORE_POSITION_KEY_MATCHES.items() - if any(term in text for term in terms) - } - return bool(matched) and not bool(matched & selected) - - -def _hardcore_subcategory_supports_positions(subcategory: dict[str, Any], config: dict[str, Any]) -> bool: - if not _hardcore_position_template_required(config): - return True - axes = subcategory.get("item_axes") - if not isinstance(axes, dict): - return True - for axis_name, values in axes.items(): - if str(axis_name) in HARDCORE_POSITION_AXIS_KEYS and any( - _hardcore_position_entry_matches(value, config) - for value in _list_from(values) - ): - return True - return False - - -def _filter_hardcore_axis(axis_name: str, values: list[Any], config: dict[str, Any]) -> list[Any]: - if not _hardcore_position_config_active(config): - return values - filtered = [ - value - for value in values - if not _hardcore_text_blocked_by_action(_entry_text(value), axis_name, config) - and not (axis_name not in HARDCORE_POSITION_AXIS_KEYS and _hardcore_position_entry_conflicts(value, config)) - and (axis_name not in HARDCORE_POSITION_AXIS_KEYS or _hardcore_position_entry_matches(value, config)) - ] - return filtered or values - - -def _filter_hardcore_templates(templates: list[Any], config: dict[str, Any]) -> list[Any]: - if not _hardcore_position_config_active(config): - return templates - filtered: list[Any] = [] - for template in templates: - text = _entry_text(template) - fields = {key for _, key, _, _ in Formatter().parse(text) if key} - blocked = _hardcore_position_template_required(config) and not bool(fields & HARDCORE_POSITION_AXIS_KEYS) - blocked = blocked or any(_hardcore_text_blocked_by_action(text, field, config) for field in fields | {""}) - if not blocked: - filtered.append(template) - return filtered or templates + return hardcore_position_policy.filter_hardcore_categories_for_position( + categories, + config, + women_count, + men_count, + _compatible_entry, + ) def _apply_hardcore_position_config_to_subcategory( subcategory: dict[str, Any], config: dict[str, Any], ) -> dict[str, Any]: - if not _hardcore_position_config_active(config): - return subcategory - subcategory_copy = dict(subcategory) - if "item_templates" in subcategory_copy: - subcategory_copy["item_templates"] = _filter_hardcore_templates(_list_from(subcategory_copy["item_templates"]), config) - raw_axes = subcategory_copy.get("item_axes") - if isinstance(raw_axes, dict): - axes = {} - for axis_name, values in raw_axes.items(): - axes[axis_name] = _filter_hardcore_axis(str(axis_name), _list_from(values), config) - subcategory_copy["item_axes"] = axes - subcategory_copy["hardcore_position_config"] = config - return subcategory_copy + return hardcore_position_policy.apply_hardcore_position_config_to_subcategory(subcategory, config) def _ratio_or_none(value: float) -> float | None: diff --git a/tools/prompt_smoke.py b/tools/prompt_smoke.py index 97da7c7..07f2c00 100644 --- a/tools/prompt_smoke.py +++ b/tools/prompt_smoke.py @@ -1242,6 +1242,69 @@ def smoke_hardcore_position_config_policy() -> None: _expect(filtered.get("allow_penetration") is False, "Hardcore outercourse focus should disable penetration") _expect("outercourse_sex" in hardcore_position_config.hardcore_allowed_subcategory_slugs(filtered), "Allowed subcategories lost outercourse") _expect("oral_sex" not in hardcore_position_config.hardcore_allowed_subcategory_slugs(filtered), "Allowed subcategories should exclude oral") + action_only = json.loads( + hardcore_position_config.build_hardcore_action_filter_json( + focus="outercourse_only", + allow_toys=False, + allow_double=False, + allow_penetration=True, + allow_foreplay=True, + allow_interaction=True, + allow_manual=True, + allow_oral=True, + allow_outercourse=True, + allow_anal=True, + allow_climax=True, + ) + ) + action_axis = hardcore_position_config.filter_hardcore_axis( + "outer_act", + ["boobjob body contact", "blowjob oral sex", "vaginal penetration"], + action_only, + ) + _expect(action_axis == ["boobjob body contact"], "Hardcore action filter policy did not block disabled oral/penetration text") + position_filtered = hardcore_position_config.apply_hardcore_position_config_to_subcategory( + { + "slug": "oral_sex", + "item_templates": [ + {"template": "oral contact in {position}"}, + {"template": "oral sex without a position axis"}, + {"template": "unsupported static template"}, + ], + "item_axes": { + "position": ["standing oral position", "kneeling oral position"], + "oral_act": ["blowjob", "cunnilingus"], + }, + }, + base, + ) + _expect( + position_filtered["item_templates"] == [{"template": "oral contact in {position}"}], + "Hardcore position policy did not filter templates by selected position requirements", + ) + _expect( + position_filtered["item_axes"]["position"] == ["standing oral position"], + "Hardcore position policy did not filter position axes by selected keys", + ) + filtered_categories = hardcore_position_config.filter_hardcore_categories_for_position( + [ + { + "name": "Hardcore sexual poses", + "slug": "hardcore_sexual_poses", + "subcategories": [{"slug": "oral_sex"}, {"slug": "outercourse_sex"}], + }, + {"name": "Casual clothes", "slug": "casual_clothes", "subcategories": [{"slug": "tops"}]}, + ], + filtered, + 1, + 1, + lambda _entry, _women, _men: True, + ) + _expect( + [entry["slug"] for entry in filtered_categories[0]["subcategories"]] == ["outercourse_sex"], + "Hardcore category filter policy did not remove disallowed subcategories", + ) + _expect(filtered_categories[1]["slug"] == "casual_clothes", "Hardcore category filter should preserve non-hardcore categories") keys = pb._hardcore_position_keys("woman on all fours from behind", axis_values={"position": "doggy"}) _expect(keys == ["doggy"], "Hardcore position key detection changed")