From 50d0ffa7e3894a19bee8aea473e72062c265e0ae Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Sat, 27 Jun 2026 00:45:37 +0200 Subject: [PATCH] Extract hardcore position config policy --- docs/prompt-architecture-improvement-plan.md | 4 + docs/prompt-pool-routing-map.md | 6 +- hardcore_position_config.py | 521 +++++++++++++++++++ node_hardcore_position.py | 4 +- prompt_builder.py | 461 ++-------------- tools/prompt_smoke.py | 60 +++ 6 files changed, 633 insertions(+), 423 deletions(-) create mode 100644 hardcore_position_config.py diff --git a/docs/prompt-architecture-improvement-plan.md b/docs/prompt-architecture-improvement-plan.md index 4986b55..98bfe60 100644 --- a/docs/prompt-architecture-improvement-plan.md +++ b/docs/prompt-architecture-improvement-plan.md @@ -117,6 +117,10 @@ Already isolated: location/composition entry parsing, merge behavior, and config parsing live in `location_config.py`; `prompt_builder.py` still applies selected configs 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. - 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 7389477..c1f0e1f 100644 --- a/docs/prompt-pool-routing-map.md +++ b/docs/prompt-pool-routing-map.md @@ -74,6 +74,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. | | `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. | @@ -306,8 +307,9 @@ Edit targets: `foreplay_teasing`, `manual_stimulation`, `body_worship_touching`, `clothing_position_transitions`, `dominant_guidance`, `camera_performance`, `group_coordination`, and `aftercare_cleanup`. -- Position filtering UI: `build_hardcore_position_pool_json`, - `build_hardcore_action_filter_json`, `_apply_hardcore_position_config_to_subcategory`. +- 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. - 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`. diff --git a/hardcore_position_config.py b/hardcore_position_config.py new file mode 100644 index 0000000..e5bcf69 --- /dev/null +++ b/hardcore_position_config.py @@ -0,0 +1,521 @@ +from __future__ import annotations + +import json +import re +from typing import Any + + +HARDCORE_POSITION_FAMILY_CHOICES = [ + "any", + "penetrative", + "foreplay", + "interaction", + "manual", + "oral", + "outercourse", + "anal", + "climax", + "threesome", + "group", +] +HARDCORE_POSITION_FOCUS_CHOICES = [ + "keep_pool", + "penetration_only", + "foreplay_only", + "interaction_only", + "manual_only", + "oral_only", + "outercourse_only", + "anal_only", + "climax_only", + "threesome_only", + "group_only", +] +HARDCORE_POSITION_KEY_CHOICES = [ + "missionary", + "cowgirl", + "reverse_cowgirl", + "doggy", + "bent_over", + "face_down_ass_up", + "standing", + "side_lying", + "edge_supported", + "kneeling", + "lotus_lap", + "face_sitting", + "sixty_nine", + "reclining_oral", + "straddled_oral", + "spread_leg_oral", + "chair_oral", + "kissing", + "caressing", + "breast_touch", + "face_touch", + "undressing", + "body_worship", + "nipple_play", + "ass_grab", + "thigh_kissing", + "hair_holding", + "wrist_pinning", + "dirty_talk", + "position_transition", + "guided_positioning", + "camera_showing", + "watching", + "aftercare", + "cleanup", + "fingering", + "clit_rubbing", + "mutual_masturbation", + "boobjob", + "testicle_sucking", + "penis_licking", + "handjob", + "footjob", + "open_thighs", + "front_back", +] +HARDCORE_POSITION_FAMILY_SUBCATEGORIES = { + "any": [ + "penetrative_sex", + "foreplay_teasing", + "body_worship_touching", + "clothing_position_transitions", + "dominant_guidance", + "camera_performance", + "manual_stimulation", + "oral_sex", + "outercourse_sex", + "anal_double_penetration", + "threesomes", + "group_coordination", + "group_sex_orgy", + "cumshot_climax", + "aftercare_cleanup", + ], + "penetrative": ["penetrative_sex"], + "foreplay": ["foreplay_teasing"], + "interaction": [ + "foreplay_teasing", + "body_worship_touching", + "clothing_position_transitions", + "dominant_guidance", + "camera_performance", + "group_coordination", + "aftercare_cleanup", + ], + "manual": ["manual_stimulation"], + "oral": ["oral_sex"], + "outercourse": ["outercourse_sex", "manual_stimulation"], + "anal": ["anal_double_penetration"], + "climax": ["cumshot_climax"], + "threesome": ["threesomes"], + "group": ["group_sex_orgy"], +} +HARDCORE_POSITION_KEY_MATCHES = { + "missionary": ("missionary", "above her", "under her"), + "cowgirl": ("cowgirl", "straddling", "straddles", "on top", "squatting on top"), + "reverse_cowgirl": ("reverse cowgirl", "facing away"), + "doggy": ("doggy", "all fours", "rear-entry", "from behind"), + "bent_over": ("bent-over", "bent over", "hips raised"), + "face_down_ass_up": ("face-down", "ass-up"), + "standing": ("standing", "stands", "braced standing"), + "side_lying": ("side-lying", "side lying", "spooning", "on the side", "on her side"), + "edge_supported": ("edge-of-bed", "edge of bed", "bed edge", "raised edge", "edge-supported"), + "kneeling": ("kneeling", "kneels", "kneeling center"), + "lotus_lap": ("lotus", "lap", "seated in a partner's lap"), + "face_sitting": ("face-sitting", "face sitting"), + "sixty_nine": ("sixty-nine", "69"), + "reclining_oral": ("reclining cunnilingus",), + "straddled_oral": ("straddled oral",), + "spread_leg_oral": ("spread-leg", "spread leg", "reclining cunnilingus"), + "chair_oral": ("chair oral",), + "kissing": ("kiss", "kissing", "mouth-to-mouth", "mouth to mouth", "lips pressed"), + "caressing": ("caress", "caressing", "hands roaming", "stroking skin", "hands sliding"), + "breast_touch": ("breast", "breasts", "nipple", "cupping breasts", "touching breasts"), + "face_touch": ("face", "cheek", "jaw", "chin", "hand on the cheek", "fingers under the chin"), + "undressing": ("undressing", "removing clothing", "removing clothes", "pulling clothing", "sliding straps", "unbuttoning"), + "body_worship": ("body worship", "worship", "kissing down", "mouth on skin", "kissing the body"), + "nipple_play": ("nipple", "nipples", "licking nipples", "sucking nipples", "nipple play"), + "ass_grab": ("ass grab", "ass-grab", "ass grabbing", "hand on the ass", "squeezing the ass"), + "thigh_kissing": ("thigh kiss", "thigh kissing", "kissing thighs", "mouth on inner thighs"), + "hair_holding": ("hair holding", "hair held", "holding hair", "hair pulled back"), + "wrist_pinning": ("wrist", "wrists", "pinning wrists", "wrists pinned", "hands pinned"), + "dirty_talk": ("dirty talk", "whispering", "mouth near the ear", "telling", "verbal teasing"), + "position_transition": ("transition", "turning around", "pulling onto the bed", "moving into position", "position change"), + "guided_positioning": ("guiding", "guided", "guides", "lifting legs", "spreading thighs", "pulling hips", "turning the body"), + "camera_showing": ("camera", "showing to camera", "presenting to camera", "spread open for camera", "creator-shot"), + "watching": ("watching", "voyeur", "waiting turn", "partner watches", "onlooker"), + "aftercare": ("aftercare", "cuddling", "kissing after", "holding close", "post-sex"), + "cleanup": ("cleanup", "wiping", "cleaning", "towel", "wet cloth"), + "fingering": ("fingering", "fingers inside", "fingers in pussy", "finger stimulation"), + "clit_rubbing": ("clit", "clitoris", "clit rubbing", "rubbing the clit", "fingers on clit"), + "mutual_masturbation": ("mutual masturbation", "both touching themselves", "masturbating together", "hands on their own bodies"), + "boobjob": ("boobjob", "titjob", "breast-sex", "breast sex"), + "testicle_sucking": ("testicle", "balls-licking", "balls licking", "balls and mouth"), + "penis_licking": ("penis-licking", "penis licking", "tongue along", "tongue licking"), + "handjob": ("handjob", "hand job", "stroking the penis", "hand stroking", "manual stimulation"), + "footjob": ("footjob", "soles", "toes curled", "feet stroking"), + "open_thighs": ("thighs open", "legs spread", "open thighs", "legs open", "reclining with thighs open"), + "front_back": ("front-and-back", "front and back", "one behind and one in front", "between two partners"), +} +HARDCORE_POSITION_AXIS_KEYS = { + "position", + "body_position", + "body_arrangement", + "arrangement", + "tease_act", + "touch_detail", + "manual_act", + "manual_detail", + "worship_act", + "transition_act", + "control_act", + "performance_act", + "coordination_act", + "aftercare_act", + "cleanup_detail", +} +HARDCORE_SOURCE_FAMILY_BY_SUBCATEGORY = { + "penetrative_sex": "penetrative", + "foreplay_teasing": "foreplay", + "body_worship_touching": "interaction", + "clothing_position_transitions": "interaction", + "dominant_guidance": "interaction", + "camera_performance": "interaction", + "manual_stimulation": "manual", + "oral_sex": "oral", + "outercourse_sex": "outercourse", + "anal_double_penetration": "anal", + "threesomes": "threesome", + "group_coordination": "interaction", + "group_sex_orgy": "group", + "cumshot_climax": "climax", + "aftercare_cleanup": "interaction", +} +FOCUS_FAMILY_BY_KEY = { + "penetration_only": "penetrative", + "foreplay_only": "foreplay", + "interaction_only": "interaction", + "manual_only": "manual", + "oral_only": "oral", + "outercourse_only": "outercourse", + "anal_only": "anal", + "climax_only": "climax", + "threesome_only": "threesome", + "group_only": "group", +} + + +def _is_false(value: Any) -> bool: + if isinstance(value, bool): + return value is False + if isinstance(value, str): + return value.strip().lower() in ("false", "0", "no", "off") + return False + + +def _list_from(value: Any) -> list[Any]: + if value is None: + return [] + if isinstance(value, list): + return value + return [value] + + +def hardcore_position_family_choices() -> list[str]: + return list(HARDCORE_POSITION_FAMILY_CHOICES) + + +def hardcore_position_focus_choices() -> list[str]: + return list(HARDCORE_POSITION_FOCUS_CHOICES) + + +def hardcore_position_key_choices() -> list[str]: + return list(HARDCORE_POSITION_KEY_CHOICES) + + +def normalize_hardcore_position_family(value: Any, default: str = "any") -> str: + text = str(value or default).strip() + return text if text in HARDCORE_POSITION_FAMILY_CHOICES else default + + +def normalize_hardcore_position_values(values: Any) -> list[str]: + raw_values = _list_from(values) + selected: list[str] = [] + for value in raw_values: + text = str(value or "").strip() + if not text or text == "any": + continue + normalized = re.sub(r"[^a-z0-9]+", "_", text.lower()).strip("_") + if normalized in HARDCORE_POSITION_KEY_CHOICES and normalized not in selected: + selected.append(normalized) + return selected + + +def empty_hardcore_position_config() -> dict[str, Any]: + return { + "config_type": "hardcore_position", + "enabled": False, + "family": "any", + "positions": [], + "require_position": False, + "allow_toys": True, + "allow_double": True, + "allow_penetration": True, + "allow_foreplay": True, + "allow_interaction": True, + "allow_manual": True, + "allow_oral": True, + "allow_outercourse": True, + "allow_anal": True, + "allow_climax": True, + } + + +def parse_hardcore_position_config(value: str | dict[str, Any] | None) -> dict[str, Any]: + if not value: + return empty_hardcore_position_config() + if isinstance(value, dict): + raw = value + else: + try: + raw = json.loads(str(value)) + except json.JSONDecodeError: + return empty_hardcore_position_config() + if not isinstance(raw, dict): + return empty_hardcore_position_config() + parsed = {**empty_hardcore_position_config(), **raw} + parsed["enabled"] = bool(parsed.get("enabled", True)) + parsed["family"] = normalize_hardcore_position_family(parsed.get("family")) + parsed["positions"] = normalize_hardcore_position_values(parsed.get("positions")) + parsed["require_position"] = not _is_false(parsed.get("require_position", False)) + for key in ( + "allow_toys", + "allow_double", + "allow_penetration", + "allow_foreplay", + "allow_interaction", + "allow_manual", + "allow_oral", + "allow_outercourse", + "allow_anal", + "allow_climax", + ): + parsed[key] = not _is_false(parsed.get(key, True)) + return parsed + + +def hardcore_position_summary(config: dict[str, Any]) -> str: + if not config.get("enabled"): + return "hardcore position unrestricted" + parts = [f"family={config.get('family', 'any')}"] + positions = config.get("positions") or [] + if positions: + parts.append("positions=" + ",".join(positions)) + elif config.get("require_position"): + parts.append("position_templates=required") + disabled = [ + label + for key, label in ( + ("allow_toys", "toys"), + ("allow_double", "double"), + ("allow_penetration", "penetration"), + ("allow_foreplay", "foreplay"), + ("allow_interaction", "interaction"), + ("allow_manual", "manual"), + ("allow_oral", "oral"), + ("allow_outercourse", "outercourse"), + ("allow_anal", "anal"), + ("allow_climax", "climax"), + ) + if not config.get(key, True) + ] + if disabled: + parts.append("blocked=" + ",".join(disabled)) + return "; ".join(parts) + + +def build_hardcore_position_pool_json( + hardcore_position_config: str | dict[str, Any] | None = "", + combine_mode: str = "replace", + family: str = "any", + selected_positions: list[str] | tuple[str, ...] | str | None = None, +) -> str: + base = parse_hardcore_position_config(hardcore_position_config) + if combine_mode == "replace": + base = {**empty_hardcore_position_config(), "enabled": True} + else: + base["enabled"] = True + base["family"] = normalize_hardcore_position_family(family, base.get("family", "any")) + selected = normalize_hardcore_position_values(selected_positions) + if combine_mode == "add": + existing = list(base.get("positions") or []) + for value in selected: + if value not in existing: + existing.append(value) + base["positions"] = existing + else: + base["positions"] = selected + base["require_position"] = bool(base.get("require_position")) or bool(base["positions"]) or base["family"] != "any" + base["summary"] = hardcore_position_summary(base) + return json.dumps(base, ensure_ascii=True, sort_keys=True) + + +def build_hardcore_action_filter_json( + hardcore_position_config: str | dict[str, Any] | None = "", + focus: str = "keep_pool", + allow_toys: bool = False, + allow_double: bool = False, + allow_penetration: bool = True, + allow_foreplay: bool = True, + allow_interaction: bool = True, + allow_manual: bool = True, + allow_oral: bool = True, + allow_outercourse: bool = True, + allow_anal: bool = True, + allow_climax: bool = True, +) -> str: + config = parse_hardcore_position_config(hardcore_position_config) + config["enabled"] = True + focus = str(focus or "keep_pool").strip() + focus_family = FOCUS_FAMILY_BY_KEY.get(focus) + if focus_family: + config["family"] = focus_family + config["allow_toys"] = bool(allow_toys) + config["allow_double"] = bool(allow_double) + config["allow_penetration"] = bool(allow_penetration) + config["allow_foreplay"] = bool(allow_foreplay) + config["allow_interaction"] = bool(allow_interaction) + config["allow_manual"] = bool(allow_manual) + config["allow_oral"] = bool(allow_oral) + config["allow_outercourse"] = bool(allow_outercourse) + config["allow_anal"] = bool(allow_anal) + config["allow_climax"] = bool(allow_climax) + + if not focus_family and config["family"] != "any": + enabled_action_families = { + family + for enabled, family in ( + (config["allow_penetration"], "penetrative"), + (config["allow_foreplay"], "foreplay"), + (config["allow_interaction"], "interaction"), + (config["allow_manual"], "manual"), + (config["allow_oral"], "oral"), + (config["allow_outercourse"], "outercourse"), + (config["allow_anal"], "anal"), + (config["allow_climax"], "climax"), + ) + if enabled + } + if config["family"] in enabled_action_families and len(enabled_action_families) > 1: + config["family"] = "any" + + if focus == "foreplay_only": + config["allow_foreplay"] = True + config["allow_interaction"] = True + elif focus == "interaction_only": + config["allow_interaction"] = True + config["allow_foreplay"] = True + elif focus == "manual_only": + config["allow_manual"] = True + elif focus == "oral_only": + config["allow_oral"] = True + config["allow_penetration"] = False + elif focus == "outercourse_only": + config["allow_outercourse"] = True + config["allow_oral"] = False + config["allow_penetration"] = False + elif focus == "anal_only": + config["allow_anal"] = True + config["allow_penetration"] = True + elif focus == "climax_only": + config["allow_climax"] = True + config["summary"] = hardcore_position_summary(config) + return json.dumps(config, ensure_ascii=True, sort_keys=True) + + +def hardcore_position_config_active(config: dict[str, Any]) -> bool: + return bool(config.get("enabled")) + + +def hardcore_position_template_required(config: dict[str, Any]) -> bool: + if not hardcore_position_config_active(config): + return False + return ( + bool(config.get("require_position")) + or bool(config.get("positions")) + or normalize_hardcore_position_family(config.get("family")) != "any" + ) + + +def hardcore_allowed_subcategory_slugs(config: dict[str, Any]) -> set[str]: + family = normalize_hardcore_position_family(config.get("family")) + allowed = set(HARDCORE_POSITION_FAMILY_SUBCATEGORIES.get(family, HARDCORE_POSITION_FAMILY_SUBCATEGORIES["any"])) + if not config.get("allow_penetration", True): + allowed.difference_update({"penetrative_sex", "anal_double_penetration", "threesomes", "group_sex_orgy"}) + if not config.get("allow_foreplay", True): + allowed.discard("foreplay_teasing") + if not config.get("allow_interaction", True): + allowed.difference_update( + { + "foreplay_teasing", + "body_worship_touching", + "clothing_position_transitions", + "dominant_guidance", + "camera_performance", + "group_coordination", + "aftercare_cleanup", + } + ) + if not config.get("allow_manual", True): + allowed.discard("manual_stimulation") + if not config.get("allow_oral", True): + allowed.discard("oral_sex") + if not config.get("allow_outercourse", True): + allowed.discard("outercourse_sex") + if not config.get("allow_anal", True): + allowed.discard("anal_double_penetration") + if not config.get("allow_climax", True): + allowed.discard("cumshot_climax") + if not config.get("allow_double", True) and family == "anal": + allowed.add("anal_double_penetration") + return allowed or set(HARDCORE_POSITION_FAMILY_SUBCATEGORIES["any"]) + + +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, "") + if family: + return family + config_family = normalize_hardcore_position_family((config or {}).get("family"), "") + return "" if config_family == "any" else config_family + + +def hardcore_position_keys(*parts: Any, axis_values: dict[str, Any] | None = None) -> list[str]: + text_parts = [str(part or "") for part in parts if str(part or "").strip()] + if isinstance(axis_values, dict): + text_parts.extend(str(value or "") for value in axis_values.values() if str(value or "").strip()) + text = " ".join(text_parts).lower() + if not text: + return [] + keys: list[str] = [] + for key, tokens in HARDCORE_POSITION_KEY_MATCHES.items(): + if any(token in text for token in tokens): + keys.append(key) + return keys + + +_normalize_hardcore_position_family = normalize_hardcore_position_family +_normalize_hardcore_position_values = normalize_hardcore_position_values +_empty_hardcore_position_config = empty_hardcore_position_config +_parse_hardcore_position_config = parse_hardcore_position_config +_hardcore_position_summary = hardcore_position_summary +_hardcore_position_config_active = hardcore_position_config_active +_hardcore_position_template_required = hardcore_position_template_required +_hardcore_allowed_subcategory_slugs = hardcore_allowed_subcategory_slugs +_hardcore_source_position_family = hardcore_source_position_family +_hardcore_position_keys = hardcore_position_keys diff --git a/node_hardcore_position.py b/node_hardcore_position.py index 7b27193..4ade145 100644 --- a/node_hardcore_position.py +++ b/node_hardcore_position.py @@ -3,7 +3,7 @@ from __future__ import annotations import json try: - from .prompt_builder import ( + from .hardcore_position_config import ( build_hardcore_action_filter_json, build_hardcore_position_pool_json, hardcore_position_family_choices, @@ -11,7 +11,7 @@ try: hardcore_position_key_choices, ) except ImportError: # Allows local smoke tests from the repository root. - from prompt_builder import ( + from hardcore_position_config import ( build_hardcore_action_filter_json, build_hardcore_position_pool_json, hardcore_position_family_choices, diff --git a/prompt_builder.py b/prompt_builder.py index 90ce216..018c551 100644 --- a/prompt_builder.py +++ b/prompt_builder.py @@ -28,6 +28,7 @@ try: 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 hardcore_position_config as hardcore_position_policy from . import location_config as location_policy from . import pair_clothing from . import pair_camera @@ -69,6 +70,7 @@ except ImportError: # Allows local smoke tests with `python -c`. import filter_config as filter_policy import generate_prompt_batches as g import generation_profile_config as generation_profile_policy + import hardcore_position_config as hardcore_position_policy import location_config as location_policy import pair_clothing import pair_camera @@ -292,221 +294,21 @@ CHARACTER_EYE_COLOR_CHOICES = [ CAMERA_DETAIL_CHOICES = camera_policy.CAMERA_DETAIL_CHOICES HARDCORE_DETAIL_DENSITY_CHOICES = ["compact", "balanced", "dense"] -HARDCORE_POSITION_FAMILY_CHOICES = [ - "any", - "penetrative", - "foreplay", - "interaction", - "manual", - "oral", - "outercourse", - "anal", - "climax", - "threesome", - "group", -] -HARDCORE_POSITION_FOCUS_CHOICES = [ - "keep_pool", - "penetration_only", - "foreplay_only", - "interaction_only", - "manual_only", - "oral_only", - "outercourse_only", - "anal_only", - "climax_only", - "threesome_only", - "group_only", -] -HARDCORE_POSITION_KEY_CHOICES = [ - "missionary", - "cowgirl", - "reverse_cowgirl", - "doggy", - "bent_over", - "face_down_ass_up", - "standing", - "side_lying", - "edge_supported", - "kneeling", - "lotus_lap", - "face_sitting", - "sixty_nine", - "reclining_oral", - "straddled_oral", - "spread_leg_oral", - "chair_oral", - "kissing", - "caressing", - "breast_touch", - "face_touch", - "undressing", - "body_worship", - "nipple_play", - "ass_grab", - "thigh_kissing", - "hair_holding", - "wrist_pinning", - "dirty_talk", - "position_transition", - "guided_positioning", - "camera_showing", - "watching", - "aftercare", - "cleanup", - "fingering", - "clit_rubbing", - "mutual_masturbation", - "boobjob", - "testicle_sucking", - "penis_licking", - "handjob", - "footjob", - "open_thighs", - "front_back", -] -HARDCORE_POSITION_FAMILY_SUBCATEGORIES = { - "any": [ - "penetrative_sex", - "foreplay_teasing", - "body_worship_touching", - "clothing_position_transitions", - "dominant_guidance", - "camera_performance", - "manual_stimulation", - "oral_sex", - "outercourse_sex", - "anal_double_penetration", - "threesomes", - "group_coordination", - "group_sex_orgy", - "cumshot_climax", - "aftercare_cleanup", - ], - "penetrative": ["penetrative_sex"], - "foreplay": ["foreplay_teasing"], - "interaction": [ - "foreplay_teasing", - "body_worship_touching", - "clothing_position_transitions", - "dominant_guidance", - "camera_performance", - "group_coordination", - "aftercare_cleanup", - ], - "manual": ["manual_stimulation"], - "oral": ["oral_sex"], - "outercourse": ["outercourse_sex", "manual_stimulation"], - "anal": ["anal_double_penetration"], - "climax": ["cumshot_climax"], - "threesome": ["threesomes"], - "group": ["group_sex_orgy"], -} -HARDCORE_POSITION_KEY_MATCHES = { - "missionary": ("missionary", "above her", "under her"), - "cowgirl": ("cowgirl", "straddling", "straddles", "on top", "squatting on top"), - "reverse_cowgirl": ("reverse cowgirl", "facing away"), - "doggy": ("doggy", "all fours", "rear-entry", "from behind"), - "bent_over": ("bent-over", "bent over", "hips raised"), - "face_down_ass_up": ("face-down", "ass-up"), - "standing": ("standing", "stands", "braced standing"), - "side_lying": ("side-lying", "side lying", "spooning", "on the side", "on her side"), - "edge_supported": ("edge-of-bed", "edge of bed", "bed edge", "raised edge", "edge-supported"), - "kneeling": ("kneeling", "kneels", "kneeling center"), - "lotus_lap": ("lotus", "lap", "seated in a partner's lap"), - "face_sitting": ("face-sitting", "face sitting"), - "sixty_nine": ("sixty-nine", "69"), - "reclining_oral": ("reclining cunnilingus",), - "straddled_oral": ("straddled oral",), - "spread_leg_oral": ("spread-leg", "spread leg", "reclining cunnilingus"), - "chair_oral": ("chair oral",), - "kissing": ("kiss", "kissing", "mouth-to-mouth", "mouth to mouth", "lips pressed"), - "caressing": ("caress", "caressing", "hands roaming", "stroking skin", "hands sliding"), - "breast_touch": ("breast", "breasts", "nipple", "cupping breasts", "touching breasts"), - "face_touch": ("face", "cheek", "jaw", "chin", "hand on the cheek", "fingers under the chin"), - "undressing": ("undressing", "removing clothing", "removing clothes", "pulling clothing", "sliding straps", "unbuttoning"), - "body_worship": ("body worship", "worship", "kissing down", "mouth on skin", "kissing the body"), - "nipple_play": ("nipple", "nipples", "licking nipples", "sucking nipples", "nipple play"), - "ass_grab": ("ass grab", "ass-grab", "ass grabbing", "hand on the ass", "squeezing the ass"), - "thigh_kissing": ("thigh kiss", "thigh kissing", "kissing thighs", "mouth on inner thighs"), - "hair_holding": ("hair holding", "hair held", "holding hair", "hair pulled back"), - "wrist_pinning": ("wrist", "wrists", "pinning wrists", "wrists pinned", "hands pinned"), - "dirty_talk": ("dirty talk", "whispering", "mouth near the ear", "telling", "verbal teasing"), - "position_transition": ("transition", "turning around", "pulling onto the bed", "moving into position", "position change"), - "guided_positioning": ("guiding", "guided", "guides", "lifting legs", "spreading thighs", "pulling hips", "turning the body"), - "camera_showing": ("camera", "showing to camera", "presenting to camera", "spread open for camera", "creator-shot"), - "watching": ("watching", "voyeur", "waiting turn", "partner watches", "onlooker"), - "aftercare": ("aftercare", "cuddling", "kissing after", "holding close", "post-sex"), - "cleanup": ("cleanup", "wiping", "cleaning", "towel", "wet cloth"), - "fingering": ("fingering", "fingers inside", "fingers in pussy", "finger stimulation"), - "clit_rubbing": ("clit", "clitoris", "clit rubbing", "rubbing the clit", "fingers on clit"), - "mutual_masturbation": ("mutual masturbation", "both touching themselves", "masturbating together", "hands on their own bodies"), - "boobjob": ("boobjob", "titjob", "breast-sex", "breast sex"), - "testicle_sucking": ("testicle", "balls-licking", "balls licking", "balls and mouth"), - "penis_licking": ("penis-licking", "penis licking", "tongue along", "tongue licking"), - "handjob": ("handjob", "hand job", "stroking the penis", "hand stroking", "manual stimulation"), - "footjob": ("footjob", "soles", "toes curled", "feet stroking"), - "open_thighs": ("thighs open", "legs spread", "open thighs", "legs open", "reclining with thighs open"), - "front_back": ("front-and-back", "front and back", "one behind and one in front", "between two partners"), -} -HARDCORE_POSITION_AXIS_KEYS = { - "position", - "body_position", - "body_arrangement", - "arrangement", - "tease_act", - "touch_detail", - "manual_act", - "manual_detail", - "worship_act", - "transition_act", - "control_act", - "performance_act", - "coordination_act", - "aftercare_act", - "cleanup_detail", -} - -HARDCORE_SOURCE_FAMILY_BY_SUBCATEGORY = { - "penetrative_sex": "penetrative", - "foreplay_teasing": "foreplay", - "body_worship_touching": "interaction", - "clothing_position_transitions": "interaction", - "dominant_guidance": "interaction", - "camera_performance": "interaction", - "manual_stimulation": "manual", - "oral_sex": "oral", - "outercourse_sex": "outercourse", - "anal_double_penetration": "anal", - "threesomes": "threesome", - "group_coordination": "interaction", - "group_sex_orgy": "group", - "cumshot_climax": "climax", - "aftercare_cleanup": "interaction", -} +HARDCORE_POSITION_FAMILY_CHOICES = hardcore_position_policy.HARDCORE_POSITION_FAMILY_CHOICES +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 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, "") - if family: - return family - config_family = _normalize_hardcore_position_family((config or {}).get("family"), "") - return "" if config_family == "any" else config_family + return hardcore_position_policy.hardcore_source_position_family(subcategory, config) def _hardcore_position_keys(*parts: Any, axis_values: dict[str, Any] | None = None) -> list[str]: - text_parts = [str(part or "") for part in parts if str(part or "").strip()] - if isinstance(axis_values, dict): - text_parts.extend(str(value or "") for value in axis_values.values() if str(value or "").strip()) - text = " ".join(text_parts).lower() - if not text: - return [] - keys: list[str] = [] - for key, tokens in HARDCORE_POSITION_KEY_MATCHES.items(): - if any(token in text for token in tokens): - keys.append(key) - return keys + return hardcore_position_policy.hardcore_position_keys(*parts, axis_values=axis_values) CAMERA_ORBIT_FRAMING_CHOICES = camera_policy.CAMERA_ORBIT_FRAMING_CHOICES @@ -1266,104 +1068,23 @@ def _parse_filter_config(filter_config: str | dict[str, Any] | None) -> dict[str def _normalize_hardcore_position_family(value: Any, default: str = "any") -> str: - text = str(value or default).strip() - return text if text in HARDCORE_POSITION_FAMILY_CHOICES else default + return hardcore_position_policy.normalize_hardcore_position_family(value, default) def _normalize_hardcore_position_values(values: Any) -> list[str]: - raw_values = _list_from(values) - selected: list[str] = [] - for value in raw_values: - text = str(value or "").strip() - if not text or text == "any": - continue - normalized = re.sub(r"[^a-z0-9]+", "_", text.lower()).strip("_") - if normalized in HARDCORE_POSITION_KEY_CHOICES and normalized not in selected: - selected.append(normalized) - return selected + return hardcore_position_policy.normalize_hardcore_position_values(values) def _empty_hardcore_position_config() -> dict[str, Any]: - return { - "config_type": "hardcore_position", - "enabled": False, - "family": "any", - "positions": [], - "require_position": False, - "allow_toys": True, - "allow_double": True, - "allow_penetration": True, - "allow_foreplay": True, - "allow_interaction": True, - "allow_manual": True, - "allow_oral": True, - "allow_outercourse": True, - "allow_anal": True, - "allow_climax": True, - } + return hardcore_position_policy.empty_hardcore_position_config() def _parse_hardcore_position_config(value: str | dict[str, Any] | None) -> dict[str, Any]: - if not value: - return _empty_hardcore_position_config() - if isinstance(value, dict): - raw = value - else: - try: - raw = json.loads(str(value)) - except json.JSONDecodeError: - return _empty_hardcore_position_config() - if not isinstance(raw, dict): - return _empty_hardcore_position_config() - parsed = {**_empty_hardcore_position_config(), **raw} - parsed["enabled"] = bool(parsed.get("enabled", True)) - parsed["family"] = _normalize_hardcore_position_family(parsed.get("family")) - parsed["positions"] = _normalize_hardcore_position_values(parsed.get("positions")) - parsed["require_position"] = not _is_false(parsed.get("require_position", False)) - for key in ( - "allow_toys", - "allow_double", - "allow_penetration", - "allow_foreplay", - "allow_interaction", - "allow_manual", - "allow_oral", - "allow_outercourse", - "allow_anal", - "allow_climax", - ): - parsed[key] = not _is_false(parsed.get(key, True)) - return parsed + return hardcore_position_policy.parse_hardcore_position_config(value) def _hardcore_position_summary(config: dict[str, Any]) -> str: - if not config.get("enabled"): - return "hardcore position unrestricted" - parts = [f"family={config.get('family', 'any')}"] - positions = config.get("positions") or [] - if positions: - parts.append("positions=" + ",".join(positions)) - elif config.get("require_position"): - parts.append("position_templates=required") - disabled = [ - label - for key, label in ( - ("allow_toys", "toys"), - ("allow_double", "double"), - ("allow_penetration", "penetration"), - ("allow_foreplay", "foreplay"), - ("allow_interaction", "interaction"), - ("allow_manual", "manual"), - ("allow_oral", "oral"), - ("allow_outercourse", "outercourse"), - ("allow_anal", "anal"), - ("allow_climax", "climax"), - ) - if not config.get(key, True) - ] - if disabled: - parts.append("blocked=" + ",".join(disabled)) - return "; ".join(parts) + return hardcore_position_policy.hardcore_position_summary(config) def build_hardcore_position_pool_json( @@ -1372,24 +1093,12 @@ def build_hardcore_position_pool_json( family: str = "any", selected_positions: list[str] | tuple[str, ...] | str | None = None, ) -> str: - base = _parse_hardcore_position_config(hardcore_position_config) - if combine_mode == "replace": - base = {**_empty_hardcore_position_config(), "enabled": True} - else: - base["enabled"] = True - base["family"] = _normalize_hardcore_position_family(family, base.get("family", "any")) - selected = _normalize_hardcore_position_values(selected_positions) - if combine_mode == "add": - existing = list(base.get("positions") or []) - for value in selected: - if value not in existing: - existing.append(value) - base["positions"] = existing - else: - base["positions"] = selected - base["require_position"] = bool(base.get("require_position")) or bool(base["positions"]) or base["family"] != "any" - base["summary"] = _hardcore_position_summary(base) - return json.dumps(base, ensure_ascii=True, sort_keys=True) + return hardcore_position_policy.build_hardcore_position_pool_json( + hardcore_position_config=hardcore_position_config, + combine_mode=combine_mode, + family=family, + selected_positions=selected_positions, + ) def build_hardcore_action_filter_json( @@ -1406,84 +1115,28 @@ def build_hardcore_action_filter_json( allow_anal: bool = True, allow_climax: bool = True, ) -> str: - config = _parse_hardcore_position_config(hardcore_position_config) - config["enabled"] = True - focus = str(focus or "keep_pool").strip() - focus_family = { - "penetration_only": "penetrative", - "foreplay_only": "foreplay", - "interaction_only": "interaction", - "manual_only": "manual", - "oral_only": "oral", - "outercourse_only": "outercourse", - "anal_only": "anal", - "climax_only": "climax", - "threesome_only": "threesome", - "group_only": "group", - }.get(focus) - if focus_family: - config["family"] = focus_family - config["allow_toys"] = bool(allow_toys) - config["allow_double"] = bool(allow_double) - config["allow_penetration"] = bool(allow_penetration) - config["allow_foreplay"] = bool(allow_foreplay) - config["allow_interaction"] = bool(allow_interaction) - config["allow_manual"] = bool(allow_manual) - config["allow_oral"] = bool(allow_oral) - config["allow_outercourse"] = bool(allow_outercourse) - config["allow_anal"] = bool(allow_anal) - config["allow_climax"] = bool(allow_climax) - - if not focus_family and config["family"] != "any": - enabled_action_families = { - family - for enabled, family in ( - (config["allow_penetration"], "penetrative"), - (config["allow_foreplay"], "foreplay"), - (config["allow_interaction"], "interaction"), - (config["allow_manual"], "manual"), - (config["allow_oral"], "oral"), - (config["allow_outercourse"], "outercourse"), - (config["allow_anal"], "anal"), - (config["allow_climax"], "climax"), - ) - if enabled - } - if config["family"] in enabled_action_families and len(enabled_action_families) > 1: - config["family"] = "any" - - if focus == "foreplay_only": - config["allow_foreplay"] = True - config["allow_interaction"] = True - elif focus == "interaction_only": - config["allow_interaction"] = True - config["allow_foreplay"] = True - elif focus == "manual_only": - config["allow_manual"] = True - elif focus == "oral_only": - config["allow_oral"] = True - config["allow_penetration"] = False - elif focus == "outercourse_only": - config["allow_outercourse"] = True - config["allow_oral"] = False - config["allow_penetration"] = False - elif focus == "anal_only": - config["allow_anal"] = True - config["allow_penetration"] = True - elif focus == "climax_only": - config["allow_climax"] = True - config["summary"] = _hardcore_position_summary(config) - return json.dumps(config, ensure_ascii=True, sort_keys=True) + return hardcore_position_policy.build_hardcore_action_filter_json( + hardcore_position_config=hardcore_position_config, + focus=focus, + allow_toys=allow_toys, + allow_double=allow_double, + allow_penetration=allow_penetration, + allow_foreplay=allow_foreplay, + allow_interaction=allow_interaction, + allow_manual=allow_manual, + allow_oral=allow_oral, + allow_outercourse=allow_outercourse, + allow_anal=allow_anal, + allow_climax=allow_climax, + ) def _hardcore_position_config_active(config: dict[str, Any]) -> bool: - return bool(config.get("enabled")) + return hardcore_position_policy.hardcore_position_config_active(config) def _hardcore_position_template_required(config: dict[str, Any]) -> bool: - if not _hardcore_position_config_active(config): - return False - return bool(config.get("require_position")) or bool(config.get("positions")) or _normalize_hardcore_position_family(config.get("family")) != "any" + return hardcore_position_policy.hardcore_position_template_required(config) def _is_hardcore_sexual_category(category: dict[str, Any]) -> bool: @@ -1491,37 +1144,7 @@ def _is_hardcore_sexual_category(category: dict[str, Any]) -> bool: def _hardcore_allowed_subcategory_slugs(config: dict[str, Any]) -> set[str]: - family = _normalize_hardcore_position_family(config.get("family")) - allowed = set(HARDCORE_POSITION_FAMILY_SUBCATEGORIES.get(family, HARDCORE_POSITION_FAMILY_SUBCATEGORIES["any"])) - if not config.get("allow_penetration", True): - allowed.difference_update({"penetrative_sex", "anal_double_penetration", "threesomes", "group_sex_orgy"}) - if not config.get("allow_foreplay", True): - allowed.discard("foreplay_teasing") - if not config.get("allow_interaction", True): - allowed.difference_update( - { - "foreplay_teasing", - "body_worship_touching", - "clothing_position_transitions", - "dominant_guidance", - "camera_performance", - "group_coordination", - "aftercare_cleanup", - } - ) - if not config.get("allow_manual", True): - allowed.discard("manual_stimulation") - if not config.get("allow_oral", True): - allowed.discard("oral_sex") - if not config.get("allow_outercourse", True): - allowed.discard("outercourse_sex") - if not config.get("allow_anal", True): - allowed.discard("anal_double_penetration") - if not config.get("allow_climax", True): - allowed.discard("cumshot_climax") - if not config.get("allow_double", True) and family == "anal": - allowed.add("anal_double_penetration") - return allowed or set(HARDCORE_POSITION_FAMILY_SUBCATEGORIES["any"]) + return hardcore_position_policy.hardcore_allowed_subcategory_slugs(config) def _filter_hardcore_categories_for_position( @@ -1993,15 +1616,15 @@ def hardcore_detail_density_choices() -> list[str]: def hardcore_position_family_choices() -> list[str]: - return list(HARDCORE_POSITION_FAMILY_CHOICES) + return hardcore_position_policy.hardcore_position_family_choices() def hardcore_position_focus_choices() -> list[str]: - return list(HARDCORE_POSITION_FOCUS_CHOICES) + return hardcore_position_policy.hardcore_position_focus_choices() def hardcore_position_key_choices() -> list[str]: - return list(HARDCORE_POSITION_KEY_CHOICES) + return hardcore_position_policy.hardcore_position_key_choices() def character_softcore_outfit_source_choices() -> list[str]: diff --git a/tools/prompt_smoke.py b/tools/prompt_smoke.py index 72e0632..03f4c1b 100644 --- a/tools/prompt_smoke.py +++ b/tools/prompt_smoke.py @@ -27,6 +27,7 @@ import caption_naturalizer # noqa: E402 import category_cast_config # noqa: E402 import category_library # noqa: E402 import filter_config # noqa: E402 +import hardcore_position_config # noqa: E402 import __init__ as sxcp_nodes # noqa: E402 import generation_profile_config # noqa: E402 import krea_formatter # noqa: E402 @@ -661,6 +662,64 @@ def smoke_filter_config_policy() -> None: _expect(pb.normalize_ethnicity_filter("random", "any", allow_random=False) == "any", "Ethnicity default normalization changed") +def smoke_hardcore_position_config_policy() -> None: + _expect( + pb.HARDCORE_POSITION_FAMILY_CHOICES is hardcore_position_config.HARDCORE_POSITION_FAMILY_CHOICES, + "Prompt builder hardcore position family choices are not delegated", + ) + _expect("outercourse_only" in hardcore_position_config.hardcore_position_focus_choices(), "Hardcore focus choices lost outercourse_only") + _expect("boobjob" in hardcore_position_config.hardcore_position_key_choices(), "Hardcore position keys lost boobjob") + + base = json.loads( + pb.build_hardcore_position_pool_json( + combine_mode="replace", + family="oral", + selected_positions=["standing", "bad value", "standing"], + ) + ) + _expect(base.get("enabled") is True, "Hardcore position pool should enable config") + _expect(base.get("family") == "oral", "Hardcore position pool lost family") + _expect(base.get("positions") == ["standing"], "Hardcore position normalization changed") + _expect(base.get("require_position") is True, "Hardcore position pool should require selected position") + + added = json.loads( + hardcore_position_config.build_hardcore_position_pool_json( + hardcore_position_config=base, + combine_mode="add", + family="any", + selected_positions=["kneeling", "standing"], + ) + ) + _expect(added.get("positions") == ["standing", "kneeling"], "Hardcore position add merge changed") + + filtered = json.loads( + pb.build_hardcore_action_filter_json( + hardcore_position_config=added, + 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, + ) + ) + _expect(filtered.get("family") == "outercourse", "Hardcore action focus did not set outercourse family") + _expect(filtered.get("allow_oral") is False, "Hardcore outercourse focus should disable oral") + _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") + + keys = pb._hardcore_position_keys("woman on all fours from behind", axis_values={"position": "doggy"}) + _expect(keys == ["doggy"], "Hardcore position key detection changed") + source_family = hardcore_position_config.hardcore_source_position_family({"slug": "manual_stimulation"}, filtered) + _expect(source_family == "manual", "Hardcore source family lookup changed") + + def smoke_category_library_route() -> None: categories = category_library.load_category_library() _expect(len(categories) >= 3, "category library should load JSON categories") @@ -2571,6 +2630,7 @@ SMOKE_CASES: list[tuple[str, Callable[[], None]]] = [ ("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), + ("hardcore_position_config_policy", smoke_hardcore_position_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),