Filter anal axis details for position compatibility

This commit is contained in:
2026-06-27 16:04:39 +02:00
parent d0f2670d9c
commit ff6195473b
6 changed files with 262 additions and 6 deletions
+1 -1
View File
@@ -158,7 +158,7 @@ Already isolated:
normalization, position-key normalization, and metadata audit errors live in normalization, position-key normalization, and metadata audit errors live in
`category_template_metadata.py`. `category_template_metadata.py`.
- row item selection, weighted item/pair choice, item-template axis filling, - 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. `prompt_builder.py` keeps public delegate wrappers.
- row category/subcategory/item route resolution lives in - row category/subcategory/item route resolution lives in
`row_category_route.py` behind `CategoryItemRoute`, covering hardcore `row_category_route.py` behind `CategoryItemRoute`, covering hardcore
+1 -1
View File
@@ -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. | | `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_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. | | `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_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_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. | | `row_role_graph.py` | Row role-graph route sequencing, including hardcore source graph construction, pose-category environment-anchor cleanup, and POV role-graph rewriting. |
+36 -3
View File
@@ -25,8 +25,41 @@ def _clean(value: Any) -> str:
return text 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) 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: if not detail:
return "" return ""
if not is_close_foreplay_text(role_graph, detail, composition): 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: def dedupe_anchor_detail(detail: str, anchor: str) -> str:
detail = _clean(detail) detail = strip_redundant_position_detail(detail)
anchor_lower = anchor.lower() anchor_lower = anchor.lower()
duplicate_phrases = { duplicate_phrases = {
"front-and-back": (r"front-and-back contact",), "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: 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: if not detail:
return "" return ""
context = position_context_text(role_graph, hard_item, "", axis_values) context = position_context_text(role_graph, hard_item, "", axis_values)
+4
View File
@@ -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) 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( def _compose_item(
rng: random.Random, rng: random.Random,
category: dict[str, Any], category: dict[str, Any],
+93 -1
View File
@@ -292,6 +292,96 @@ def outercourse_axis_values_for_position(values: list[Any], position: str, axis_
return values 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: def _format(template: str, context: dict[str, Any]) -> str:
fields = {key for _, key, _, _ in Formatter().parse(template) if key} fields = {key for _, key, _, _ in Formatter().parse(template) if key}
safe_context = SafeFormatDict({key: "" for key in fields}) safe_context = SafeFormatDict({key: "" for key in fields})
@@ -317,7 +407,7 @@ def compose_item(
unique_fields = list(dict.fromkeys(fields)) unique_fields = list(dict.fromkeys(fields))
axis_values: dict[str, str] = {} axis_values: dict[str, str] = {}
subcategory_slug = str(subcategory.get("slug") or "").lower() 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) position_values = category_policy.compatible_entries(axes["position"], women_count, men_count)
axis_values["position"] = entry_text(weighted_choice(rng, position_values)) axis_values["position"] = entry_text(weighted_choice(rng, position_values))
for name in unique_fields: for name in unique_fields:
@@ -337,6 +427,8 @@ def compose_item(
values = outercourse_acts_for_position(values, axis_values.get("position", "")) values = outercourse_acts_for_position(values, axis_values.get("position", ""))
if subcategory_slug == "outercourse_sex": if subcategory_slug == "outercourse_sex":
values = outercourse_axis_values_for_position(values, axis_values.get("position", ""), name) 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)) axis_values[name] = entry_text(weighted_choice(rng, values))
item_prompt = _format(template, axis_values).strip() item_prompt = _format(template, axis_values).strip()
name = item_name(item) or subcategory["name"] name = item_name(item) or subcategory["name"]
+127
View File
@@ -51,6 +51,7 @@ import generation_profile_config # noqa: E402
import index_switch_policy # noqa: E402 import index_switch_policy # noqa: E402
import node_tooltips # noqa: E402 import node_tooltips # noqa: E402
import krea_cast # noqa: E402 import krea_cast # noqa: E402
import krea_action_details # noqa: E402
import krea_configured_cast_formatter # noqa: E402 import krea_configured_cast_formatter # noqa: E402
import krea_format_route # noqa: E402 import krea_format_route # noqa: E402
import krea_formatter # 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)") _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: def smoke_krea_row_fields_policy() -> None:
row = { row = {
"subject_type": "configured_cast", "subject_type": "configured_cast",
@@ -1665,6 +1716,18 @@ def smoke_row_item_policy() -> None:
== ["soles pressing around shaft"], == ["soles pressing around shaft"],
"Row item outercourse texture axis should prefer footjob-compatible details", "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 = {} category = {}
subcategory = { 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(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") _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: def smoke_row_category_route_policy() -> None:
hard_config = hardcore_position_config.parse_hardcore_position_config(_position_filter("oral_only", "oral", ["kneeling"])) 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") _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: def smoke_insta_pair_pov() -> None:
pair = pb.build_insta_of_pair( pair = pb.build_insta_of_pair(
row_number=1, row_number=1,
@@ -7510,6 +7635,7 @@ SMOKE_CASES: list[tuple[str, Callable[[], None]]] = [
("builder_prompt_route_policy", smoke_builder_prompt_route_policy), ("builder_prompt_route_policy", smoke_builder_prompt_route_policy),
("builder_config_route_policy", smoke_builder_config_route_policy), ("builder_config_route_policy", smoke_builder_config_route_policy),
("krea_normal_row_routes", smoke_krea_normal_row_routes), ("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), ("krea_row_fields_policy", smoke_krea_row_fields_policy),
("location_config_policy", smoke_location_config_policy), ("location_config_policy", smoke_location_config_policy),
("row_location_policy", smoke_row_location_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), ("pair_builder_policy", smoke_pair_builder_policy),
("insta_pair_same_cast", smoke_insta_pair), ("insta_pair_same_cast", smoke_insta_pair),
("krea_pair_clothing_state", smoke_krea_pair_clothing_state), ("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_pov_man", smoke_insta_pair_pov),
("insta_pair_camera_split", smoke_insta_pair_camera_split), ("insta_pair_camera_split", smoke_insta_pair_camera_split),
("pov_camera_scene", smoke_pov_camera_scene), ("pov_camera_scene", smoke_pov_camera_scene),