From 002c3b79d438f77e444f94685c7173be633c73ac Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Sat, 27 Jun 2026 16:15:27 +0200 Subject: [PATCH] Align outercourse action routing --- docs/prompt-architecture-improvement-plan.md | 6 +- docs/prompt-pool-routing-map.md | 3 +- hardcore_role_outercourse.py | 55 +++++--- krea_action_context.py | 6 + krea_action_details.py | 71 +++++++++- krea_action_positions.py | 15 +- krea_pov_actions.py | 34 +++-- outercourse_action_policy.py | 137 +++++++++++++++++++ row_item.py | 55 +++++--- tools/prompt_smoke.py | 78 +++++++++-- 10 files changed, 393 insertions(+), 67 deletions(-) create mode 100644 outercourse_action_policy.py diff --git a/docs/prompt-architecture-improvement-plan.md b/docs/prompt-architecture-improvement-plan.md index da68304..3fd205c 100644 --- a/docs/prompt-architecture-improvement-plan.md +++ b/docs/prompt-architecture-improvement-plan.md @@ -160,6 +160,9 @@ Already isolated: - row item selection, weighted item/pair choice, item-template axis filling, and oral/outercourse/anal axis compatibility filters live in `row_item.py`; `prompt_builder.py` keeps public delegate wrappers. +- outercourse action-kind classification for boobjob, testicle-sucking, + penis-licking, handjob, and footjob lives in `outercourse_action_policy.py` + and is shared by row item filtering, role graphs, and Krea action cleanup. - row category/subcategory/item route resolution lives in `row_category_route.py` behind `CategoryItemRoute`, covering hardcore position-category filtering, cast-count adjustment, pose-vs-content seed-axis @@ -257,7 +260,8 @@ Already isolated: dominant guidance, camera performance, aftercare, and group coordination. - outercourse-specific role graph wording has started moving into action-family modules; `hardcore_role_outercourse.py` owns boobjob, testicle-sucking, - penis-licking, handjob, and footjob body geometry. + penis-licking, handjob, and footjob body geometry, keyed by + `outercourse_action_policy.py`. - oral-specific role graph wording lives in `hardcore_role_oral.py`, including direct POV viewer phrasing for kneeling, face-sitting, sixty-nine, edge-supported, side-lying, chair, standing, and reclining oral positions. diff --git a/docs/prompt-pool-routing-map.md b/docs/prompt-pool-routing-map.md index 6bf18e3..bdf9769 100644 --- a/docs/prompt-pool-routing-map.md +++ b/docs/prompt-pool-routing-map.md @@ -82,6 +82,7 @@ Core helper ownership: | `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/anal axis compatibility filters. | +| `outercourse_action_policy.py` | Shared boobjob, testicle-sucking, penis-licking, handjob, and footjob action-kind classification used by row selection, role graphs, and Krea detail routing. | | `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. | @@ -118,7 +119,7 @@ Core helper ownership: | `hardcore_role_fallback.py` | Solo, same-sex, mixed group fallback, and support-partner role graph wording for configured casts. | | `hardcore_role_interaction.py` | Foreplay, manual stimulation, body worship, clothing transition, dominant guidance, camera performance, aftercare, and group coordination role graph wording. | | `hardcore_role_oral.py` | Oral-sex role graph wording for kneeling, face-sitting, sixty-nine, edge-supported, side-lying, chair, standing, and reclining oral geometry. | -| `hardcore_role_outercourse.py` | Outercourse role graph wording for boobjob, testicle-sucking, penis-licking, handjob, and footjob geometry. | +| `hardcore_role_outercourse.py` | Outercourse role graph wording for boobjob, testicle-sucking, penis-licking, handjob, and footjob geometry, keyed by `outercourse_action_policy.py`. | | `hardcore_role_penetration.py` | Penetrative-sex role graph wording for missionary, cowgirl, reverse-cowgirl, doggy, standing, side-lying, raised-edge, kneeling-straddle, and lotus geometry. | | `hardcore_role_anal.py` | Anal and double-contact role graph wording for rear-entry, raised-edge, kneeling, side-lying, and front/back double-position geometry. | | `hardcore_role_climax.py` | Climax and ejaculation aftermath role graph wording for face/body/ass, lap, open-thigh, side-lying, and group front/back placement. | diff --git a/hardcore_role_outercourse.py b/hardcore_role_outercourse.py index 55b34e7..1233843 100644 --- a/hardcore_role_outercourse.py +++ b/hardcore_role_outercourse.py @@ -2,6 +2,11 @@ from __future__ import annotations from typing import Any +try: + from . import outercourse_action_policy as outercourse_policy +except ImportError: # Allows local smoke tests with top-level imports. + import outercourse_action_policy as outercourse_policy + def _context_text(item_text: str, item_axis_values: dict[str, Any] | None) -> str: return " ".join( @@ -22,38 +27,46 @@ def build_outercourse_role_graph( ) -> str: position_text = str((item_axis_values or {}).get("position") or "").lower() text = _context_text(item_text, item_axis_values) + action_kind = outercourse_policy.infer_outercourse_action_kind(position_text) + if action_kind == outercourse_policy.OUTERCOURSE_GENERIC: + action_kind = outercourse_policy.infer_outercourse_action_kind(text) man_is_pov = man in set(pov_labels or []) - if any(term in text for term in ("boobjob", "titjob", "breast-sex", "breast sex")): + if action_kind == outercourse_policy.OUTERCOURSE_BOOBJOB: if man_is_pov: return ( - f"{woman} kneels between the POV viewer's open thighs with her torso bent forward over his pelvis and shoulders low, " - "both hands lifting and pressing her breasts tightly around the POV viewer's penis shaft while the glans sits just below her lips." + f"{woman} kneels low between the POV viewer's open thighs with her torso bent forward over his pelvis, " + "both hands pushing her breasts inward around the POV viewer's penis, the penis held between her breasts in the lower foreground, " + "her chin and lips directly above the glans at the tip." ) return ( - f"{woman} kneels between {man}'s open thighs with her torso bent forward over his pelvis and shoulders low while {man} sits with legs apart, " - f"{woman}'s hands lifting and pressing her breasts tightly around {man}'s penis shaft while the glans sits just below her lips." + f"{man} sits with legs apart while {woman} kneels low between his open thighs with her torso bent forward over his pelvis, " + f"{woman}'s hands pushing her breasts inward around {man}'s penis, the penis held between her breasts, " + "her chin and lips directly above the glans at the tip." ) - if any(term in text for term in ("testicle", "balls-licking", "balls licking", "balls and mouth", "balls held")): + if action_kind == outercourse_policy.OUTERCOURSE_TESTICLE: if man_is_pov: return ( - f"{woman} kneels very low between the POV viewer's open thighs with her torso bent forward and shoulders between his knees, " - "head tucked under the penis shaft at the base of the penis, mouth and tongue on the POV viewer's balls while his penis points upward above her face." + f"{woman} bends forward and kneels very low between the POV viewer's open thighs with her shoulders between his knees, " + "her face below the POV viewer's penis at testicle height, mouth and tongue on the POV viewer's balls, " + "while his penis points upward in the lower foreground above her forehead." ) return ( f"{man} sits with legs apart while {woman} kneels very low between his open thighs with her torso bent forward and shoulders between his knees, " - f"head tucked under the penis shaft at the base of his penis, mouth and tongue on his balls while {man}'s penis points upward above her face." + f"{woman}'s face below {man}'s penis at testicle height, mouth and tongue on his balls, while {man}'s penis points upward above her forehead." ) - if "penis-licking" in position_text or "penis licking" in text or "tongue along" in text or "tongue licking" in text: + if action_kind == outercourse_policy.OUTERCOURSE_PENIS_LICKING: if man_is_pov: return ( - f"{woman} bends forward between the POV viewer's open thighs, head low under the POV viewer's penis with her face directly under the penis, " - "tongue running along the underside from the penis shaft to the glans while one hand steadies the base of the penis." + f"{woman} bends forward between the POV viewer's open thighs with her head low under the POV viewer's penis, " + "her face just under the penis while her tongue touches the underside from the base toward the glans at the tip, " + "one hand steadying the base of the POV viewer's penis." ) return ( - f"{woman} bends forward between {man}'s open thighs, head low under {man}'s penis with her face directly under the penis, " - f"tongue running along the underside from the penis shaft to the glans while one hand steadies the base of the penis." + f"{woman} bends forward between {man}'s open thighs with her head low under {man}'s penis, " + f"her face just under the penis while her tongue touches the underside from the base toward the glans at the tip, " + f"one hand steadying the base of {man}'s penis." ) - if "footjob" in text or "soles" in text or "toes curled" in text or "feet stroking" in text: + if action_kind == outercourse_policy.OUTERCOURSE_FOOTJOB: if man_is_pov: return ( f"{woman} faces the POV viewer with her hips back, torso visible behind her raised legs, and both knees bent open toward the camera, " @@ -63,15 +76,17 @@ def build_outercourse_role_graph( f"{man} reclines with hips forward while {woman} faces him with her hips back and both knees bent open, " f"wrapping both soles around {man}'s penis shaft while the contact stays centered." ) - if "handjob" in position_text or "handjob" in text or "hand job" in text or "hand wrapped" in text: + if action_kind == outercourse_policy.OUTERCOURSE_HANDJOB: if man_is_pov: return ( - f"{woman} kneels between the POV viewer's open thighs with her torso leaning forward and face visible behind the penis shaft, " - "one hand wrapped around the POV viewer's penis shaft while the other hand steadies the base of the penis as she strokes toward the glans." + f"{woman} kneels between the POV viewer's open thighs with her torso leaning forward and face visible behind the POV viewer's penis, " + "one hand grips and strokes the POV viewer's penis in the lower foreground while the other hand steadies its base, " + "thumb and fingers visible around the penis as she strokes toward the glans." ) return ( - f"{woman} kneels between {man}'s open thighs with her torso leaning forward and face visible behind the penis shaft, " - f"one hand wrapped around {man}'s penis shaft while the other hand steadies the base of the penis as she strokes toward the glans." + f"{woman} kneels between {man}'s open thighs with her torso leaning forward and face visible behind {man}'s penis, " + f"one hand grips and strokes {man}'s penis while the other hand steadies its base, " + "thumb and fingers visible around the penis as she strokes toward the glans." ) if man_is_pov: return ( diff --git a/krea_action_context.py b/krea_action_context.py index c9f5de1..c9d8e9f 100644 --- a/krea_action_context.py +++ b/krea_action_context.py @@ -32,6 +32,12 @@ def axis_values_text(axis_values: Any) -> str: "surface", "body_contact", "leg_detail", + "outer_act", + "contact_detail", + "texture_detail", + "hand_detail", + "visibility", + "expression_detail", "oral_act", "oral_detail", "penetration_act", diff --git a/krea_action_details.py b/krea_action_details.py index 70c023f..37d4775 100644 --- a/krea_action_details.py +++ b/krea_action_details.py @@ -4,12 +4,14 @@ import re from typing import Any try: + from . import outercourse_action_policy as outercourse_policy from .krea_action_context import ( is_close_foreplay_text, position_context_text, ) from .krea_detail import detail_clauses, join_detail_clauses except ImportError: # Allows local smoke tests with `python -c`. + import outercourse_action_policy as outercourse_policy from krea_action_context import ( is_close_foreplay_text, position_context_text, @@ -253,11 +255,16 @@ def dedupe_outercourse_detail(detail: str, role_graph: str, hard_item: str = "", return "" context = position_context_text(role_graph, hard_item, "", axis_values) context_lower = context.lower() - breast_sex = any(term in context_lower for term in ("boobjob", "titjob", "breast sex", "breast-sex")) + position_text = "" + if isinstance(axis_values, dict): + position_text = _clean(axis_values.get("position", "")).lower() + action_kind = outercourse_policy.infer_outercourse_action_kind(position_text) + if action_kind == outercourse_policy.OUTERCOURSE_GENERIC: + action_kind = outercourse_policy.infer_outercourse_action_kind(context_lower) clauses: list[str] = [] for clause in detail_clauses(detail): lower = clause.lower() - if breast_sex: + if action_kind == outercourse_policy.OUTERCOURSE_BOOBJOB: if lower in ("penis", "breasts", "mouth clearly visible"): continue if any( @@ -283,6 +290,66 @@ def dedupe_outercourse_detail(detail: str, role_graph: str, hard_item: str = "", ) ): continue + elif action_kind == outercourse_policy.OUTERCOURSE_TESTICLE: + if any( + term in lower + for term in ( + "testicle", + "balls licking", + "balls-licking", + "balls held", + "balls close", + "balls and mouth", + "mouth and tongue", + "mouth visible", + "mouth contact", + ) + ): + continue + elif action_kind == outercourse_policy.OUTERCOURSE_PENIS_LICKING: + if any( + term in lower + for term in ( + "penis licking", + "penis-licking", + "tongue along", + "tongue licking", + "underside of the penis", + "tongue contact on the penis", + "one hand steadies the base", + ) + ): + continue + elif action_kind == outercourse_policy.OUTERCOURSE_HANDJOB: + if any( + term in lower + for term in ( + "handjob", + "hand job", + "hand wrapped", + "one hand wrapped", + "two-handed", + "both hands stroking", + "hand and penis centered", + "fingers and palm visibly stroking", + ) + ): + continue + elif action_kind == outercourse_policy.OUTERCOURSE_FOOTJOB: + if any( + term in lower + for term in ( + "footjob", + "foot job", + "both soles", + "soles pressing", + "soles wrap", + "toes curled", + "feet and penis", + "soles and penis", + ) + ): + continue clauses.append(clause) return join_detail_clauses(clauses) diff --git a/krea_action_positions.py b/krea_action_positions.py index 436a93a..af31a4b 100644 --- a/krea_action_positions.py +++ b/krea_action_positions.py @@ -4,6 +4,7 @@ import re from typing import Any try: + from . import outercourse_action_policy as outercourse_policy from .krea_action_context import ( axis_values_text, is_close_foreplay_text, @@ -13,6 +14,7 @@ try: position_context_text, ) except ImportError: # Allows local smoke tests with `python -c`. + import outercourse_action_policy as outercourse_policy from krea_action_context import ( axis_values_text, is_close_foreplay_text, @@ -51,15 +53,18 @@ def hardcore_pose_anchor(role_graph: str, hard_item: str, composition: str = "", if is_foreplay_text(role_graph, hard_item, composition, axis_values_text(axis_values)): return "" if is_outercourse_text(role_graph, hard_item, composition, axis_values_text(axis_values)): - if any(term in text for term in ("boobjob", "titjob", "breast sex", "breast-sex")): + action_kind = outercourse_policy.infer_outercourse_action_kind(position_text) + if action_kind == outercourse_policy.OUTERCOURSE_GENERIC: + action_kind = outercourse_policy.infer_outercourse_action_kind(text) + if action_kind == outercourse_policy.OUTERCOURSE_BOOBJOB: return "breast-sex outercourse pose" - if any(term in text for term in ("testicle", "balls licking", "balls-licking", "balls and mouth")): + if action_kind == outercourse_policy.OUTERCOURSE_TESTICLE: return "testicle-sucking outercourse pose" - if any(term in text for term in ("penis licking", "penis-licking", "tongue along", "tongue licking")): + if action_kind == outercourse_policy.OUTERCOURSE_PENIS_LICKING: return "penis-licking outercourse pose" - if any(term in text for term in ("handjob", "hand job", "hand wrapped", "hand stroking", "manual stimulation")): + if action_kind == outercourse_policy.OUTERCOURSE_HANDJOB: return "handjob outercourse pose" - if any(term in text for term in ("footjob", "soles", "toes curled", "feet stroking")): + if action_kind == outercourse_policy.OUTERCOURSE_FOOTJOB: return "footjob outercourse pose" return "non-penetrative outercourse pose" if is_toy_assisted_double_text(role_graph, hard_item, composition, axis_values_text(axis_values)): diff --git a/krea_pov_actions.py b/krea_pov_actions.py index 4d99a13..87f7ca6 100644 --- a/krea_pov_actions.py +++ b/krea_pov_actions.py @@ -4,6 +4,7 @@ import re from typing import Any try: + from . import outercourse_action_policy as outercourse_policy from .krea_action_context import ( axis_values_text, is_climax_text, @@ -13,6 +14,7 @@ try: ) from .krea_detail import limit_detail_for_density except ImportError: # Allows local smoke tests with `python -c`. + import outercourse_action_policy as outercourse_policy from krea_action_context import ( axis_values_text, is_climax_text, @@ -163,27 +165,33 @@ def pov_hardcore_pose_sentence( ) if is_outercourse_text(context, action_lower): - if any(term in context for term in ("boobjob", "titjob", "breast sex", "breast-sex")): + action_kind = outercourse_policy.infer_outercourse_action_kind(position_text) + if action_kind == outercourse_policy.OUTERCOURSE_GENERIC: + action_kind = outercourse_policy.infer_outercourse_action_kind(context, action_lower) + if action_kind == outercourse_policy.OUTERCOURSE_BOOBJOB: return outercourse_sentence( - "The woman kneels between the viewer's open thighs with her torso bent forward over his pelvis and shoulders low; " - "both hands lift and press her breasts tightly around the viewer's penis shaft in the lower foreground, with the glans just below her lips" + "The woman kneels low between the viewer's open thighs with her torso bent forward over his pelvis; " + "both hands push her breasts inward around the viewer's penis in the lower foreground, the penis held between her breasts, " + "with her chin and lips directly above the glans at the tip" ) - if any(term in context for term in ("testicle", "balls licking", "balls-licking", "balls and mouth")): + if action_kind == outercourse_policy.OUTERCOURSE_TESTICLE: return outercourse_sentence( - "The woman kneels very low between the viewer's open thighs with her torso bent forward and shoulders between his knees; " - "her head is tucked under the penis shaft at the base of the penis, mouth and tongue licking the viewer's balls while his penis points upward above her face in the lower foreground" + "The woman bends forward and kneels very low between the viewer's open thighs with her shoulders between his knees; " + "her face is below the viewer's penis at testicle height, mouth and tongue licking the viewer's balls while his penis points upward in the lower foreground above her forehead" ) - if any(term in context for term in ("penis licking", "penis-licking", "tongue along", "tongue licking")): + if action_kind == outercourse_policy.OUTERCOURSE_PENIS_LICKING: return outercourse_sentence( - "The woman bends forward between the viewer's open thighs, head low under the viewer's penis with her face directly under the penis; " - "her tongue runs along the underside from the penis shaft to the glans while one hand steadies the base of the penis in the lower foreground" + "The woman bends forward between the viewer's open thighs with her head low under the viewer's penis; " + "her face is just under the penis while her tongue touches the underside from the base toward the glans at the tip, " + "one hand steadying the base of the viewer's penis in the lower foreground" ) - if any(term in context for term in ("handjob", "hand job", "hand wrapped", "hand stroking", "manual stimulation")): + if action_kind == outercourse_policy.OUTERCOURSE_HANDJOB: return outercourse_sentence( - "The woman kneels between the viewer's open thighs with her torso leaning forward and face visible behind the penis shaft; " - "one hand wraps around the penis shaft in the lower foreground while the other hand steadies the base of the penis as she strokes toward the glans" + "The woman kneels between the viewer's open thighs with her torso leaning forward and face visible behind the viewer's penis; " + "one hand grips and strokes the viewer's penis in the lower foreground while the other hand steadies its base, " + "thumb and fingers visible around the penis as she strokes toward the glans" ) - if any(term in context for term in ("footjob", "soles", "toes curled", "feet stroking")): + if action_kind == outercourse_policy.OUTERCOURSE_FOOTJOB: return outercourse_sentence( "The woman faces the viewer with her hips back, torso visible behind her raised legs, and both knees bent open toward the camera; " "her soles wrap around the penis shaft in the lower foreground, toes curled around the penis shaft with her face visible beyond her feet" diff --git a/outercourse_action_policy.py b/outercourse_action_policy.py new file mode 100644 index 0000000..7ea05e5 --- /dev/null +++ b/outercourse_action_policy.py @@ -0,0 +1,137 @@ +from __future__ import annotations + +import re +from typing import Any + + +OUTERCOURSE_BOOBJOB = "boobjob" +OUTERCOURSE_TESTICLE = "testicle_sucking" +OUTERCOURSE_PENIS_LICKING = "penis_licking" +OUTERCOURSE_HANDJOB = "handjob" +OUTERCOURSE_FOOTJOB = "footjob" +OUTERCOURSE_GENERIC = "generic" + + +OUTERCOURSE_ACTION_KIND_CHOICES = { + OUTERCOURSE_BOOBJOB, + OUTERCOURSE_TESTICLE, + OUTERCOURSE_PENIS_LICKING, + OUTERCOURSE_HANDJOB, + OUTERCOURSE_FOOTJOB, + OUTERCOURSE_GENERIC, +} + + +def _clean(value: Any) -> str: + text = "" if value is None else str(value) + text = text.replace("\n", " ") + text = re.sub(r"\s+", " ", text).strip() + text = re.sub(r"\s+([,.;:])", r"\1", text) + return text + + +def normalize_outercourse_action_kind(value: Any, default: str = OUTERCOURSE_GENERIC) -> str: + text = re.sub(r"[^a-z0-9]+", "_", _clean(value).lower()).strip("_") + aliases = { + "breast_sex": OUTERCOURSE_BOOBJOB, + "titjob": OUTERCOURSE_BOOBJOB, + "tit_job": OUTERCOURSE_BOOBJOB, + "testicle": OUTERCOURSE_TESTICLE, + "testicles": OUTERCOURSE_TESTICLE, + "ball_licking": OUTERCOURSE_TESTICLE, + "balls_licking": OUTERCOURSE_TESTICLE, + "balls": OUTERCOURSE_TESTICLE, + "penis_lick": OUTERCOURSE_PENIS_LICKING, + "penis_tongue": OUTERCOURSE_PENIS_LICKING, + "hand_job": OUTERCOURSE_HANDJOB, + "two_handed_handjob": OUTERCOURSE_HANDJOB, + "foot_job": OUTERCOURSE_FOOTJOB, + "feet_job": OUTERCOURSE_FOOTJOB, + } + text = aliases.get(text, text) + return text if text in OUTERCOURSE_ACTION_KIND_CHOICES else default + + +def infer_outercourse_action_kind(*parts: Any) -> str: + text = " ".join(_clean(part).lower() for part in parts if _clean(part)) + if not text: + return OUTERCOURSE_GENERIC + if any( + term in text + for term in ( + "boobjob", + "titjob", + "tit job", + "breast sex", + "breast-sex", + "breasts tightly around", + "breasts around", + "breasts firmly together", + "penis squeezed between both breasts", + "penis shaft compressed between breasts", + "soft flesh squeezed around the penis", + ) + ): + return OUTERCOURSE_BOOBJOB + if any( + term in text + for term in ( + "testicle", + "balls licking", + "balls-licking", + "balls held", + "balls close", + "balls and mouth", + "mouth and tongue on the viewer's balls", + "mouth and tongue on the pov viewer's balls", + "mouth and tongue licking the viewer's balls", + "mouth and tongue licking the pov viewer's balls", + ) + ): + return OUTERCOURSE_TESTICLE + if any( + term in text + for term in ( + "penis licking", + "penis-licking", + "tongue along", + "tongue runs along", + "tongue running along", + "tongue licking", + "underside of the penis", + ) + ): + return OUTERCOURSE_PENIS_LICKING + if any( + term in text + for term in ( + "handjob", + "hand job", + "hand wrapped", + "hand wraps around", + "hand stroking", + "both hands stroking", + "two-handed", + "one hand grips", + "one hand wrapped around", + ) + ): + return OUTERCOURSE_HANDJOB + if any( + term in text + for term in ( + "footjob", + "foot job", + "soles", + "toes curled", + "feet stroking", + "feet and penis", + "both feet", + ) + ): + return OUTERCOURSE_FOOTJOB + return OUTERCOURSE_GENERIC + + +def outercourse_context_text(*parts: Any) -> str: + return " ".join(_clean(part).lower() for part in parts if _clean(part)) diff --git a/row_item.py b/row_item.py index b4c5b21..d4302be 100644 --- a/row_item.py +++ b/row_item.py @@ -8,10 +8,12 @@ try: from . import category_library as category_policy from . import category_template_metadata as template_policy from . import generate_prompt_batches as g + from . import outercourse_action_policy as outercourse_policy except ImportError: # Allows local smoke tests with top-level imports. import category_library as category_policy import category_template_metadata as template_policy import generate_prompt_batches as g + import outercourse_action_policy as outercourse_policy class SafeFormatDict(dict): @@ -182,8 +184,8 @@ def oral_axis_values_for_context(values: list[Any], position: str, oral_act: str def outercourse_acts_for_position(values: list[Any], position: str) -> list[Any]: - position_text = str(position or "").lower() - if not position_text: + action_kind = outercourse_policy.infer_outercourse_action_kind(position) + if action_kind == outercourse_policy.OUTERCOURSE_GENERIC: return values def act_text(value: Any) -> str: @@ -193,22 +195,22 @@ def outercourse_acts_for_position(values: list[Any], position: str) -> list[Any] matches = [value for value in values if predicate(act_text(value))] return matches or values - if any(term in position_text for term in ("boobjob", "titjob", "breast-sex", "breast sex")): + if action_kind == outercourse_policy.OUTERCOURSE_BOOBJOB: return filtered(lambda text: any(term in text for term in ("boobjob", "titjob", "breast sex", "breasts"))) - if any(term in position_text for term in ("testicle", "balls")): + if action_kind == outercourse_policy.OUTERCOURSE_TESTICLE: return filtered(lambda text: any(term in text for term in ("testicle", "balls"))) - if "penis-licking" in position_text or "penis licking" in position_text: + if action_kind == outercourse_policy.OUTERCOURSE_PENIS_LICKING: return filtered(lambda text: "licking" in text or "tongue" in text) - if "handjob" in position_text or "hand job" in position_text: + if action_kind == outercourse_policy.OUTERCOURSE_HANDJOB: return filtered(lambda text: any(term in text for term in ("handjob", "hand job", "hand wrapped", "two-handed"))) - if "footjob" in position_text: + if action_kind == outercourse_policy.OUTERCOURSE_FOOTJOB: return filtered(lambda text: any(term in text for term in ("footjob", "feet", "soles", "toes"))) return values def outercourse_axis_values_for_position(values: list[Any], position: str, axis_name: str) -> list[Any]: - position_text = str(position or "").lower() - if not position_text: + action_kind = outercourse_policy.infer_outercourse_action_kind(position) + if action_kind == outercourse_policy.OUTERCOURSE_GENERIC: return values axis_name = str(axis_name or "").lower() if axis_name not in {"contact_detail", "hand_detail", "texture_detail", "visibility", "body_contact"}: @@ -224,15 +226,25 @@ def outercourse_axis_values_for_position(values: list[Any], position: str, axis_ if any(term in value_text(value) for term in terms) and not any(term in value_text(value) for term in excluded_terms) ] - return matches or values + 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 any(term in position_text for term in ("boobjob", "titjob", "breast-sex", "breast sex")): + if action_kind == outercourse_policy.OUTERCOURSE_BOOBJOB: by_axis = { "contact_detail": ("compressed", "glans", "breast", "breasts", "soft tissue", "skin visibly"), "hand_detail": ("breast", "breasts", "fingers"), "texture_detail": ("compression", "soft flesh", "skin", "flesh", "asymmetry"), "visibility": ("breast", "breasts", "glans", "shaft"), - "body_contact": ("torso", "body angled", "shoulders", "hips"), + "body_contact": ("torso", "body angle", "body angled", "shoulders", "hips"), } excluded_by_axis = { "contact_detail": ("hand wrapped", "fingers and palm", "soles", "toes", "balls", "tongue"), @@ -245,7 +257,7 @@ def outercourse_axis_values_for_position(values: list[Any], position: str, axis_ by_axis.get(axis_name, ("breast", "breasts", "shaft")), excluded_by_axis.get(axis_name, ()), ) - if any(term in position_text for term in ("testicle", "balls")): + if action_kind == outercourse_policy.OUTERCOURSE_TESTICLE: by_axis = { "contact_detail": ("balls", "lips", "tongue", "wet"), "hand_detail": ("balls", "base", "thigh"), @@ -254,7 +266,7 @@ def outercourse_axis_values_for_position(values: list[Any], position: str, axis_ "body_contact": ("torso", "shoulders", "head tucked", "base of the penis", "knees", "thigh"), } return filtered(by_axis.get(axis_name, ("balls", "mouth", "tongue"))) - if "penis-licking" in position_text or "penis licking" in position_text: + if action_kind == outercourse_policy.OUTERCOURSE_PENIS_LICKING: by_axis = { "contact_detail": ("tongue", "lips", "glans", "shaft", "wet"), "hand_detail": ("base", "penis", "thigh"), @@ -263,7 +275,7 @@ def outercourse_axis_values_for_position(values: list[Any], position: str, axis_ "body_contact": ("head low", "face directly", "torso", "pelvis", "base of the penis", "hips", "body angled"), } return filtered(by_axis.get(axis_name, ("tongue", "glans", "shaft"))) - if "handjob" in position_text or "hand job" in position_text: + if action_kind == outercourse_policy.OUTERCOURSE_HANDJOB: by_axis = { "contact_detail": ("hand", "fingers", "palm", "shaft", "glans"), "hand_detail": ("hand", "hands", "shaft", "penis"), @@ -271,8 +283,17 @@ def outercourse_axis_values_for_position(values: list[Any], position: str, axis_ "visibility": ("hand", "penis", "shaft", "glans"), "body_contact": ("hips", "knees", "body angle"), } - return filtered(by_axis.get(axis_name, ("hand", "penis", "shaft"))) - if "footjob" in position_text: + excluded_by_axis = { + "contact_detail": ("balls", "soles", "toes", "breast", "breasts", "tongue"), + "hand_detail": ("balls", "thigh", "ankles", "breast", "breasts"), + "texture_detail": ("toes", "soles", "tongue", "breast", "breasts"), + "visibility": ("balls", "feet", "soles", "breast", "mouth"), + } + return filtered( + by_axis.get(axis_name, ("hand", "penis", "shaft")), + excluded_by_axis.get(axis_name, ()), + ) + if action_kind == outercourse_policy.OUTERCOURSE_FOOTJOB: by_axis = { "contact_detail": ("soles", "toes"), "hand_detail": ("ankles", "thighs"), diff --git a/tools/prompt_smoke.py b/tools/prompt_smoke.py index eae56cd..78ab414 100644 --- a/tools/prompt_smoke.py +++ b/tools/prompt_smoke.py @@ -48,10 +48,12 @@ import hardcore_action_metadata # noqa: E402 import hardcore_position_config # noqa: E402 import __init__ as sxcp_nodes # noqa: E402 import generation_profile_config # noqa: E402 +import hardcore_role_outercourse # 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_action_context # noqa: E402 import krea_configured_cast_formatter # noqa: E402 import krea_format_route # noqa: E402 import krea_formatter # noqa: E402 @@ -67,6 +69,7 @@ import pair_clothing # noqa: E402 import pair_rows # noqa: E402 import prompt_hygiene # noqa: E402 import prompt_builder as pb # noqa: E402 +import outercourse_action_policy # noqa: E402 import pov_policy # noqa: E402 import row_normalization # noqa: E402 import row_assembly # noqa: E402 @@ -1181,6 +1184,60 @@ def smoke_krea_action_details_policy() -> None: ) +def smoke_outercourse_action_policy() -> None: + _expect( + outercourse_action_policy.infer_outercourse_action_kind("kneeling boobjob position") + == outercourse_action_policy.OUTERCOURSE_BOOBJOB, + "Outercourse classifier lost boobjob position detection", + ) + _expect( + outercourse_action_policy.infer_outercourse_action_kind("bent-over balls-licking position") + == outercourse_action_policy.OUTERCOURSE_TESTICLE, + "Outercourse classifier lost testicle/balls position detection", + ) + _expect( + outercourse_action_policy.infer_outercourse_action_kind("tongue runs along the underside of the penis") + == outercourse_action_policy.OUTERCOURSE_PENIS_LICKING, + "Outercourse classifier lost penis-licking detail detection", + ) + _expect( + outercourse_action_policy.infer_outercourse_action_kind("two-handed handjob with thumb and fingers around the penis") + == outercourse_action_policy.OUTERCOURSE_HANDJOB, + "Outercourse classifier lost handjob detection", + ) + _expect( + outercourse_action_policy.infer_outercourse_action_kind("footjob with toes curled around the penis") + == outercourse_action_policy.OUTERCOURSE_FOOTJOB, + "Outercourse classifier lost footjob detection", + ) + axis_text = krea_action_context.axis_values_text( + { + "outer_act": "handjob with one hand wrapped around the penis", + "contact_detail": "hand wrapped around the penis shaft with the glans visible", + "visibility": "hand and penis centered in frame", + } + ) + _expect("handjob" in axis_text and "hand and penis" in axis_text, "Krea action context lost outercourse axes") + role_graph = hardcore_role_outercourse.build_outercourse_role_graph( + "Woman A", + "Man A", + "testicle sucking with lips around the balls", + {"position": "bent-over testicle-sucking position"}, + ["Man A"], + ) + lower_role = role_graph.lower() + _expect("face below the pov viewer's penis at testicle height" in lower_role, "POV testicle role graph lost low-head geometry") + _expect("penis points upward" in lower_role, "POV testicle role graph lost foreground penis geometry") + deduped = krea_action_details.dedupe_outercourse_detail( + "testicle sucking with lips around the balls, balls and mouth contact visible, wet lips and tongue contact", + role_graph, + "testicle sucking with lips around the balls", + {"position": "bent-over testicle-sucking position"}, + ).lower() + _expect("testicle" not in deduped and "balls and mouth" not in deduped, "Krea outercourse dedupe kept redundant testicle clauses") + _expect("wet lips" in deduped, "Krea outercourse dedupe removed useful texture clause") + + def smoke_krea_row_fields_policy() -> None: row = { "subject_type": "configured_cast", @@ -5503,26 +5560,26 @@ def smoke_pov_outercourse_position_routes() -> None: ( "pov_outercourse_boobjob", "boobjob", - ("breasts tightly around", "glans sits just below"), - ("press her breasts tightly around", "glans just below", "penis shaft"), + ("breasts inward around", "directly above the glans"), + ("push her breasts inward around", "directly above the glans", "held between her breasts"), ), ( "pov_outercourse_testicle", "testicle_sucking", - ("mouth and tongue on the pov viewer's balls", "penis points upward"), - ("mouth and tongue licking", "balls", "penis points upward"), + ("face below the pov viewer's penis at testicle height", "penis points upward"), + ("face is below the viewer's penis at testicle height", "mouth and tongue licking", "penis points upward"), ), ( "pov_outercourse_penis_licking", "penis_licking", - ("head low under the pov viewer's penis", "tongue running along"), - ("tongue runs along", "penis shaft", "glans"), + ("head low under the pov viewer's penis", "tongue touches the underside"), + ("tongue touches the underside", "glans at the tip", "viewer"), ), ( "pov_outercourse_handjob", "handjob", - ("one hand wrapped around the pov viewer's penis", "strokes toward the glans"), - ("one hand wraps around", "penis shaft", "strokes toward the glans"), + ("one hand grips and strokes the pov viewer's penis", "strokes toward the glans"), + ("one hand grips and strokes the viewer's penis", "thumb and fingers", "strokes toward the glans"), ), ( "pov_outercourse_footjob", @@ -5572,6 +5629,10 @@ def smoke_pov_outercourse_position_routes() -> None: _expect("camera:" not in krea.get("krea_prompt", ""), f"{name} Krea prompt emitted normal third-person camera directive") for term in krea_terms: _expect(term in prompt, f"{name} Krea prompt missing {term!r}: {prompt}") + if position_key == "handjob": + _expect("behind the penis shaft" not in prompt, f"{name} Krea prompt kept vague shaft-only wording: {prompt}") + if position_key == "testicle_sucking": + _expect("head is tucked under the penis shaft" not in prompt, f"{name} Krea prompt kept high-head testicle wording: {prompt}") def smoke_pov_oral_position_routes() -> None: @@ -7636,6 +7697,7 @@ SMOKE_CASES: list[tuple[str, Callable[[], None]]] = [ ("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), + ("outercourse_action_policy", smoke_outercourse_action_policy), ("krea_row_fields_policy", smoke_krea_row_fields_policy), ("location_config_policy", smoke_location_config_policy), ("row_location_policy", smoke_row_location_policy),