From ff6195473b01d28c2f64256c239e977fd7c39df1 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Sat, 27 Jun 2026 16:04:39 +0200 Subject: [PATCH] Filter anal axis details for position compatibility --- docs/prompt-architecture-improvement-plan.md | 2 +- docs/prompt-pool-routing-map.md | 2 +- krea_action_details.py | 39 +++++- prompt_builder.py | 4 + row_item.py | 94 +++++++++++++- tools/prompt_smoke.py | 127 +++++++++++++++++++ 6 files changed, 262 insertions(+), 6 deletions(-) diff --git a/docs/prompt-architecture-improvement-plan.md b/docs/prompt-architecture-improvement-plan.md index b9b3a60..da68304 100644 --- a/docs/prompt-architecture-improvement-plan.md +++ b/docs/prompt-architecture-improvement-plan.md @@ -158,7 +158,7 @@ Already isolated: normalization, position-key normalization, and metadata audit errors live in `category_template_metadata.py`. - row item selection, weighted item/pair choice, item-template axis filling, - and oral/outercourse axis compatibility filters live in `row_item.py`; + and oral/outercourse/anal axis compatibility filters live in `row_item.py`; `prompt_builder.py` keeps public delegate wrappers. - row category/subcategory/item route resolution lives in `row_category_route.py` behind `CategoryItemRoute`, covering hardcore diff --git a/docs/prompt-pool-routing-map.md b/docs/prompt-pool-routing-map.md index 16bb5c3..6bf18e3 100644 --- a/docs/prompt-pool-routing-map.md +++ b/docs/prompt-pool-routing-map.md @@ -81,7 +81,7 @@ Core helper ownership: | `builder_config_route.py` | Config-driven prompt-builder request parsing, category/cast/profile/filter helper-node mapping, and direct `build_prompt` kwarg assembly. | | `category_extensions.py` | JSON `pool_extensions`, legacy pool patching, built-in category choice lists, and category/subcategory UI choices. | | `category_template_metadata.py` | Object-style and inherited item-template metadata extraction, action/position family normalization, position-key normalization, key merging, formatter-hint merging, and audit validation errors. | -| `row_item.py` | Row item selection, weighted item/pair choice, item-template axis filling, and oral/outercourse axis compatibility filters. | +| `row_item.py` | Row item selection, weighted item/pair choice, item-template axis filling, and oral/outercourse/anal axis compatibility filters. | | `row_category_route.py` | Row category/subcategory/item route resolution behind `CategoryItemRoute`, hardcore position-category filtering, cast-count adjustment, pose-vs-content seed-axis choice, item metadata collection, legacy dict compatibility, and pose-category item sanitizing. | | `row_rendering.py` | Row prompt/caption text-field resolution, template selection, safe formatting, default prompt templates, configured-cast descriptor insertion, and POV directive insertion. | | `row_role_graph.py` | Row role-graph route sequencing, including hardcore source graph construction, pose-category environment-anchor cleanup, and POV role-graph rewriting. | diff --git a/krea_action_details.py b/krea_action_details.py index 8a879a7..70c023f 100644 --- a/krea_action_details.py +++ b/krea_action_details.py @@ -25,8 +25,41 @@ def _clean(value: Any) -> str: return text -def sanitize_foreplay_detail(detail: str, role_graph: str = "", composition: str = "") -> str: +def strip_redundant_position_detail(detail: str) -> str: detail = _clean(detail) + if not detail: + return "" + detail = re.sub( + r"^\s*[^,;]*?\bposition\b\s+(?:while|featuring|with)\s+", + "", + detail, + flags=re.IGNORECASE, + ) + detail = re.sub( + r"^\s*[^,;]*?\bposition\b,\s*", + "", + detail, + flags=re.IGNORECASE, + ) + detail = re.sub( + r"\s+\bin\s+[^,;]*?\bposition\b", + "", + detail, + flags=re.IGNORECASE, + ) + detail = re.sub( + r"\s+\bfrom\s+[^,;]*?\bposition\b", + "", + detail, + flags=re.IGNORECASE, + ) + detail = re.sub(r"\s*,\s*", ", ", detail) + detail = re.sub(r",\s*,", ",", detail) + return _clean(detail).strip(" ,;") + + +def sanitize_foreplay_detail(detail: str, role_graph: str = "", composition: str = "") -> str: + detail = strip_redundant_position_detail(detail) if not detail: return "" if not is_close_foreplay_text(role_graph, detail, composition): @@ -127,7 +160,7 @@ def hardcore_item_detail(hard_item: str) -> str: def dedupe_anchor_detail(detail: str, anchor: str) -> str: - detail = _clean(detail) + detail = strip_redundant_position_detail(detail) anchor_lower = anchor.lower() duplicate_phrases = { "front-and-back": (r"front-and-back contact",), @@ -215,7 +248,7 @@ def dedupe_toy_double_detail(detail: str) -> str: def dedupe_outercourse_detail(detail: str, role_graph: str, hard_item: str = "", axis_values: Any = None) -> str: - detail = _clean(detail) + detail = strip_redundant_position_detail(detail) if not detail: return "" context = position_context_text(role_graph, hard_item, "", axis_values) diff --git a/prompt_builder.py b/prompt_builder.py index 02fc859..2f12a09 100644 --- a/prompt_builder.py +++ b/prompt_builder.py @@ -306,6 +306,10 @@ def _outercourse_axis_values_for_position(values: list[Any], position: str, axis return row_item_policy.outercourse_axis_values_for_position(values, position, axis_name) +def _anal_axis_values_for_position(values: list[Any], position: str, axis_name: str) -> list[Any]: + return row_item_policy.anal_axis_values_for_position(values, position, axis_name) + + def _compose_item( rng: random.Random, category: dict[str, Any], diff --git a/row_item.py b/row_item.py index f303212..b4c5b21 100644 --- a/row_item.py +++ b/row_item.py @@ -292,6 +292,96 @@ def outercourse_axis_values_for_position(values: list[Any], position: str, axis_ return values +def anal_axis_values_for_position(values: list[Any], position: str, axis_name: str) -> list[Any]: + position_text = str(position or "").lower() + if not position_text: + return values + axis_name = str(axis_name or "").lower() + if axis_name not in {"body_contact", "hand_detail", "leg_detail", "thrust_detail", "visibility"}: + return values + + def value_text(value: Any) -> str: + return entry_text(value).lower() + + def filtered(terms: tuple[str, ...], excluded_terms: tuple[str, ...] = ()) -> list[Any]: + matches = [ + value + for value in values + if any(term in value_text(value) for term in terms) + and not any(term in value_text(value) for term in excluded_terms) + ] + if matches: + return matches + if excluded_terms: + non_excluded = [ + value + for value in values + if not any(term in value_text(value) for term in excluded_terms) + ] + if non_excluded: + return non_excluded + return values + + if "side-lying" in position_text or "spooning" in position_text: + by_axis = { + "body_contact": ("bodies locked", "chests pressed", "sweaty", "hips pressed"), + "hand_detail": ("hips", "waist", "cheeks", "shoulders"), + "leg_detail": ("one leg lifted", "thighs held open", "legs spread"), + "thrust_detail": ("pelvis pressed", "bodies rocking", "wet skin", "hard grinding"), + "visibility": ("ass and penis", "anal penetration", "spread cheeks", "genital contact"), + } + return filtered( + by_axis.get(axis_name, ("side", "thigh", "hips")), + ("standing", "kneeling", "draped over shoulders", "knees pressed to chest"), + ) + if "standing" in position_text: + by_axis = { + "body_contact": ("hips pressed", "bodies locked", "one body bent over", "ass lifted", "sweaty"), + "hand_detail": ("hips", "waist", "cheeks", "shoulders"), + "leg_detail": ("standing", "one foot planted"), + "thrust_detail": ("hips", "pelvis", "hard grinding", "bodies rocking"), + "visibility": ("ass and penis", "anal penetration", "spread cheeks", "genital contact"), + } + return filtered( + by_axis.get(axis_name, ("standing", "hips")), + ("kneeling", "draped over shoulders", "knees pressed to chest", "side-lying"), + ) + if "edge-of-bed" in position_text or "bed-edge" in position_text or "edge supported" in position_text: + by_axis = { + "body_contact": ("thighs held open", "hips pressed", "bodies locked", "ass lifted"), + "hand_detail": ("hips", "waist", "cheeks", "thighs"), + "leg_detail": ("knees pressed", "legs draped", "thighs held open", "one foot planted"), + "thrust_detail": ("hips", "pelvis", "hard grinding", "bodies rocking"), + "visibility": ("ass and penis", "anal penetration", "open thighs", "genital contact"), + } + return filtered(by_axis.get(axis_name, ("thigh", "hips")), ("standing", "side-lying")) + if "kneeling" in position_text: + by_axis = { + "body_contact": ("ass lifted", "hips pressed", "bodies locked", "one body bent over"), + "hand_detail": ("hips", "waist", "cheeks", "thighs"), + "leg_detail": ("kneeling", "thighs held open", "legs spread"), + "thrust_detail": ("hips", "pelvis", "ass pushed", "hard grinding"), + "visibility": ("ass and penis", "anal penetration", "spread cheeks", "genital contact"), + } + return filtered( + by_axis.get(axis_name, ("kneeling", "hips")), + ("standing", "draped over shoulders", "knees pressed to chest", "side-lying"), + ) + if "doggy" in position_text or "face-down" in position_text or "bent-over" in position_text: + by_axis = { + "body_contact": ("ass lifted", "one body bent over", "hips pressed", "bodies locked"), + "hand_detail": ("hips", "waist", "cheeks", "thighs"), + "leg_detail": ("legs spread", "kneeling", "one foot planted", "standing"), + "thrust_detail": ("ass pushed", "hips", "pelvis", "hard grinding"), + "visibility": ("ass and penis", "anal penetration", "spread cheeks", "genital contact"), + } + excluded = ("side-lying", "draped over shoulders", "knees pressed to chest") + if "face-down" in position_text or "doggy" in position_text: + excluded = (*excluded, "standing") + return filtered(by_axis.get(axis_name, ("ass", "hips")), excluded) + return values + + def _format(template: str, context: dict[str, Any]) -> str: fields = {key for _, key, _, _ in Formatter().parse(template) if key} safe_context = SafeFormatDict({key: "" for key in fields}) @@ -317,7 +407,7 @@ def compose_item( unique_fields = list(dict.fromkeys(fields)) axis_values: dict[str, str] = {} subcategory_slug = str(subcategory.get("slug") or "").lower() - if subcategory_slug in ("oral_sex", "outercourse_sex") and "position" in unique_fields and axes.get("position"): + if subcategory_slug in ("oral_sex", "outercourse_sex", "anal_double_penetration") and "position" in unique_fields and axes.get("position"): position_values = category_policy.compatible_entries(axes["position"], women_count, men_count) axis_values["position"] = entry_text(weighted_choice(rng, position_values)) for name in unique_fields: @@ -337,6 +427,8 @@ def compose_item( values = outercourse_acts_for_position(values, axis_values.get("position", "")) if subcategory_slug == "outercourse_sex": values = outercourse_axis_values_for_position(values, axis_values.get("position", ""), name) + if subcategory_slug == "anal_double_penetration": + values = anal_axis_values_for_position(values, axis_values.get("position", ""), name) axis_values[name] = entry_text(weighted_choice(rng, values)) item_prompt = _format(template, axis_values).strip() name = item_name(item) or subcategory["name"] diff --git a/tools/prompt_smoke.py b/tools/prompt_smoke.py index 08ec8d5..eae56cd 100644 --- a/tools/prompt_smoke.py +++ b/tools/prompt_smoke.py @@ -51,6 +51,7 @@ import generation_profile_config # noqa: E402 import index_switch_policy # noqa: E402 import node_tooltips # noqa: E402 import krea_cast # noqa: E402 +import krea_action_details # noqa: E402 import krea_configured_cast_formatter # noqa: E402 import krea_format_route # noqa: E402 import krea_formatter # noqa: E402 @@ -1130,6 +1131,56 @@ def smoke_krea_normal_row_routes() -> None: _expect_krea_normal_route_parity(generic, "krea_normal_generic", "metadata(generic)") +def smoke_krea_action_details_policy() -> None: + _expect( + krea_action_details.strip_redundant_position_detail( + "kneeling penis-licking position while slow tongue licking on the underside of the penis" + ) + == "slow tongue licking on the underside of the penis", + "Krea action detail cleanup should remove leading position-while scaffolding", + ) + _expect( + krea_action_details.strip_redundant_position_detail( + "raised edge fingering position featuring mutual masturbation with both bodies touching themselves" + ) + == "mutual masturbation with both bodies touching themselves", + "Krea action detail cleanup should remove leading position-featuring scaffolding", + ) + _expect( + krea_action_details.strip_redundant_position_detail( + "footjob with toes curled around the penis shaft in seated footjob position" + ) + == "footjob with toes curled around the penis shaft", + "Krea action detail cleanup should remove trailing in-position scaffolding", + ) + _expect( + "position while" + not in krea_action_details.dedupe_outercourse_detail( + "kneeling penis-licking position while slow tongue licking on the underside of the penis", + "the woman bends forward between the man's open thighs", + "penis licking", + {"position": "kneeling penis-licking position"}, + ).lower(), + "Krea outercourse detail cleanup leaked position-while scaffolding", + ) + _expect( + "position featuring" + not in krea_action_details.sanitize_foreplay_detail( + "raised edge fingering position featuring mutual masturbation with both bodies touching themselves", + "the woman and man sit close facing each other", + ).lower(), + "Krea foreplay/manual detail cleanup leaked position-featuring scaffolding", + ) + _expect( + krea_action_details.dedupe_anchor_detail( + "side-lying anal position, one leg lifted high", + "side-lying rear-entry anal pose", + ) + == "one leg lifted high", + "Krea anchored detail cleanup should remove repeated anal position prefix", + ) + + def smoke_krea_row_fields_policy() -> None: row = { "subject_type": "configured_cast", @@ -1665,6 +1716,18 @@ def smoke_row_item_policy() -> None: == ["soles pressing around shaft"], "Row item outercourse texture axis should prefer footjob-compatible details", ) + anal_leg_values = [ + "standing with legs braced", + "one leg lifted high", + "kneeling with thighs apart", + "knees pressed to chest", + ] + _expect( + pb._anal_axis_values_for_position(anal_leg_values, "side-lying anal position", "leg_detail") + == row_item.anal_axis_values_for_position(anal_leg_values, "side-lying anal position", "leg_detail") + == ["one leg lifted high"], + "Row item anal leg-detail filtering changed for side-lying anal", + ) category = {} subcategory = { @@ -1693,6 +1756,33 @@ def smoke_row_item_policy() -> None: _expect(axis_values.get("hand_detail") == "hands on hips", "Row item compose did not apply oral detail filter") _expect(metadata.get("action_family") == "oral", "Row item compose lost template metadata") + anal_subcategory = { + "name": "Anal and double penetration", + "slug": "anal_double_penetration", + "item_templates": [ + { + "template": "{anal_act} in {position}, with {leg_detail}", + "action_family": "default", + "position_family": "anal", + } + ], + "item_axes": { + "position": ["side-lying anal position"], + "anal_act": ["penis entering ass"], + "leg_detail": anal_leg_values, + }, + } + anal_text, _anal_name, anal_axis_values, _anal_metadata = row_item.compose_item( + random.Random(5), + {}, + anal_subcategory, + "Anal and double penetration", + women_count=1, + men_count=1, + ) + _expect("standing with legs braced" not in anal_text, "Row item compose leaked standing legs into side-lying anal") + _expect(anal_axis_values.get("leg_detail") == "one leg lifted high", "Row item compose did not apply anal leg-detail filter") + def smoke_row_category_route_policy() -> None: hard_config = hardcore_position_config.parse_hardcore_position_config(_position_filter("oral_only", "oral", ["kneeling"])) @@ -5212,6 +5302,41 @@ def smoke_krea_pair_clothing_state() -> None: _expect("outfit racks" not in sdxl_lower and "shoe shelves" not in sdxl_lower, "SDXL pair formatter leaked unsanitized hard scene") +def smoke_krea_anal_axis_compatibility() -> None: + pair = pb.build_insta_of_pair( + row_number=1, + start_index=1, + seed=6252, + ethnicity="french_european", + figure="random", + no_plus_women=False, + no_black=False, + trigger=Trigger, + prepend_trigger_to_prompt=True, + options_json=_insta_options(hardcore_clothing_continuity="partially_removed", camera_detail="off"), + character_cast=_character_cast(), + hardcore_position_config=_action_filter( + "anal_only", + pb.build_hardcore_position_pool_json(family="anal"), + ), + seed_config=pb.build_seed_lock_config_json(base_seed=6252, reroll_axis="pose", reroll_seed=6352), + ) + hard_row = pair["hardcore_row"] + axis_values = hard_row.get("item_axis_values") or {} + position = str(axis_values.get("position") or "").lower() + leg_detail = str(axis_values.get("leg_detail") or "").lower() + if "side-lying" in position: + _expect("standing" not in leg_detail, "Generated side-lying anal row leaked standing leg detail") + krea = krea_formatter.format_krea2_prompt("", metadata_json=_json(pair), target="hardcore") + prompt = _expect_text("krea_anal_axis_compatibility.krea_prompt", krea.get("krea_prompt"), 60) + lower = prompt.lower() + _expect( + "side-lying rear-entry anal pose" not in lower or "stands braced" not in lower, + "Krea anal formatter mixed side-lying anchor with standing role graph", + ) + _expect("side-lying anal position, standing with legs braced" not in lower, "Krea anal formatter leaked contradictory axis detail") + + def smoke_insta_pair_pov() -> None: pair = pb.build_insta_of_pair( row_number=1, @@ -7510,6 +7635,7 @@ SMOKE_CASES: list[tuple[str, Callable[[], None]]] = [ ("builder_prompt_route_policy", smoke_builder_prompt_route_policy), ("builder_config_route_policy", smoke_builder_config_route_policy), ("krea_normal_row_routes", smoke_krea_normal_row_routes), + ("krea_action_details_policy", smoke_krea_action_details_policy), ("krea_row_fields_policy", smoke_krea_row_fields_policy), ("location_config_policy", smoke_location_config_policy), ("row_location_policy", smoke_row_location_policy), @@ -7554,6 +7680,7 @@ SMOKE_CASES: list[tuple[str, Callable[[], None]]] = [ ("pair_builder_policy", smoke_pair_builder_policy), ("insta_pair_same_cast", smoke_insta_pair), ("krea_pair_clothing_state", smoke_krea_pair_clothing_state), + ("krea_anal_axis_compatibility", smoke_krea_anal_axis_compatibility), ("insta_pair_pov_man", smoke_insta_pair_pov), ("insta_pair_camera_split", smoke_insta_pair_camera_split), ("pov_camera_scene", smoke_pov_camera_scene),