Filter anal axis details for position compatibility
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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. |
|
||||
|
||||
+36
-3
@@ -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)
|
||||
|
||||
@@ -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],
|
||||
|
||||
+93
-1
@@ -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"]
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user