diff --git a/hardcore_position_config.py b/hardcore_position_config.py index 7c6fd00..50b9fdd 100644 --- a/hardcore_position_config.py +++ b/hardcore_position_config.py @@ -240,6 +240,29 @@ def _entry_text(item: Any) -> str: return str(item).strip() +def _metadata_tokens(item: Any, keys: tuple[str, ...]) -> set[str]: + if not isinstance(item, dict): + return set() + tokens: set[str] = set() + for key in keys: + for value in _list_from(item.get(key)): + token = re.sub(r"[^a-z0-9]+", "_", str(value or "").strip().lower()).strip("_") + if token and token != "any": + tokens.add(token) + return tokens + + +def _entry_position_keys(item: Any) -> list[str]: + if not isinstance(item, dict): + return [] + values: list[Any] = [] + if item.get("position_keys") is not None: + values.extend(_list_from(item.get("position_keys"))) + if item.get("position_key") is not None: + values.append(item.get("position_key")) + return normalize_hardcore_position_values(values) + + def hardcore_position_family_choices() -> list[str]: return list(HARDCORE_POSITION_FAMILY_CHOICES) @@ -633,10 +656,42 @@ def hardcore_text_blocked_by_action(text: str, axis_name: str, config: dict[str, return False +def hardcore_entry_blocked_by_action(entry: Any, axis_name: str, config: dict[str, Any]) -> bool: + action_tokens = _metadata_tokens(entry, ("action_family", "action_type")) + family_tokens = _metadata_tokens(entry, ("position_family", "family")) + position_keys = set(_entry_position_keys(entry)) + route_tokens = action_tokens | family_tokens + + if not config.get("allow_toys", True) and action_tokens & {"toy", "toy_double"}: + return True + if not config.get("allow_double", True) and (action_tokens & {"double", "toy_double"} or "front_back" in position_keys): + return True + if not config.get("allow_anal", True) and "anal" in route_tokens: + return True + if not config.get("allow_oral", True) and "oral" in route_tokens: + return True + if not config.get("allow_outercourse", True) and "outercourse" in route_tokens: + return True + if not config.get("allow_penetration", True) and route_tokens & {"penetration", "penetrative", "toy_double", "anal"}: + return True + if not config.get("allow_foreplay", True) and "foreplay" in route_tokens: + return True + if not config.get("allow_interaction", True) and "interaction" in route_tokens: + return True + if not config.get("allow_manual", True) and "manual" in route_tokens: + return True + if not config.get("allow_climax", True) and "climax" in route_tokens: + return True + return hardcore_text_blocked_by_action(_entry_text(entry), axis_name, config) + + def hardcore_position_entry_matches(entry: Any, config: dict[str, Any]) -> bool: positions = config.get("positions") or [] if not positions: return True + metadata_keys = _entry_position_keys(entry) + if metadata_keys: + return bool(set(metadata_keys) & set(positions)) text = _entry_text(entry).lower() for position in positions: if any(term in text for term in HARDCORE_POSITION_KEY_MATCHES.get(position, ())): @@ -648,12 +703,16 @@ def hardcore_position_entry_conflicts(entry: Any, config: dict[str, Any]) -> boo 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) - } + metadata_keys = _entry_position_keys(entry) + if metadata_keys: + matched = set(metadata_keys) + else: + 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) @@ -678,7 +737,7 @@ def filter_hardcore_axis(axis_name: str, values: list[Any], config: dict[str, An filtered = [ value for value in values - if not hardcore_text_blocked_by_action(_entry_text(value), axis_name, config) + if not hardcore_entry_blocked_by_action(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)) ] @@ -692,8 +751,10 @@ def filter_hardcore_templates(templates: list[Any], config: dict[str, Any]) -> l 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 | {""}) + has_position_route = bool(fields & HARDCORE_POSITION_AXIS_KEYS) or bool(_entry_position_keys(template)) + blocked = hardcore_position_template_required(config) and not has_position_route + blocked = blocked or hardcore_entry_blocked_by_action(template, "", config) + 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 diff --git a/tools/prompt_smoke.py b/tools/prompt_smoke.py index 7bdc7e5..182944b 100644 --- a/tools/prompt_smoke.py +++ b/tools/prompt_smoke.py @@ -3735,28 +3735,51 @@ def smoke_hardcore_position_config_policy() -> None: action_only, ) _expect(action_axis == ["boobjob body contact"], "Hardcore action filter policy did not block disabled oral/penetration text") + action_axis_metadata = hardcore_position_config.filter_hardcore_axis( + "outer_act", + [ + {"text": "generic contact route", "action_family": "outercourse", "position_family": "outercourse"}, + {"text": "generic contact route", "action_family": "oral", "position_family": "oral"}, + {"text": "generic contact route", "action_family": "penetration", "position_family": "penetrative"}, + ], + action_only, + ) + _expect( + action_axis_metadata == [{"text": "generic contact route", "action_family": "outercourse", "position_family": "outercourse"}], + "Hardcore action filter policy did not honor structured action metadata", + ) position_filtered = hardcore_position_config.apply_hardcore_position_config_to_subcategory( { "slug": "oral_sex", "item_templates": [ {"template": "oral contact in {position}"}, + {"template": "metadata-specific oral contact", "position_key": "standing", "action_family": "oral"}, {"template": "oral sex without a position axis"}, {"template": "unsupported static template"}, ], "item_axes": { - "position": ["standing oral position", "kneeling oral position"], + "position": [ + "standing oral position", + "kneeling oral position", + {"text": "generic standing pose", "position_key": "standing"}, + {"text": "generic kneeling pose", "position_key": "kneeling"}, + ], "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", + position_filtered["item_templates"] + == [ + {"template": "oral contact in {position}"}, + {"template": "metadata-specific oral contact", "position_key": "standing", "action_family": "oral"}, + ], + "Hardcore position policy did not filter templates by selected position requirements or metadata", ) _expect( - position_filtered["item_axes"]["position"] == ["standing oral position"], - "Hardcore position policy did not filter position axes by selected keys", + position_filtered["item_axes"]["position"] == ["standing oral position", {"text": "generic standing pose", "position_key": "standing"}], + "Hardcore position policy did not filter position axes by selected keys or metadata", ) filtered_categories = hardcore_position_config.filter_hardcore_categories_for_position( [