From b46b709e8a5001af7ca88e40012d96c7bce0abb6 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Sat, 27 Jun 2026 09:20:59 +0200 Subject: [PATCH] Move action expression sanitizer --- docs/prompt-architecture-improvement-plan.md | 4 +- docs/prompt-pool-routing-map.md | 4 +- prompt_builder.py | 91 ++----------------- row_expression.py | 95 ++++++++++++++++++++ tools/prompt_smoke.py | 19 ++++ 5 files changed, 123 insertions(+), 90 deletions(-) diff --git a/docs/prompt-architecture-improvement-plan.md b/docs/prompt-architecture-improvement-plan.md index dbf582f..7361151 100644 --- a/docs/prompt-architecture-improvement-plan.md +++ b/docs/prompt-architecture-improvement-plan.md @@ -179,8 +179,8 @@ Already isolated: wrappers. - row expression text cleanup, expression intensity weighting, character-slot/cast expression override resolution, and per-character - expression picking live in `row_expression.py`; `prompt_builder.py` keeps - public delegate wrappers. + expression picking plus action-aware character-expression sanitizing live in + `row_expression.py`; `prompt_builder.py` keeps public delegate wrappers. - hardcore position/action-filter choices, selected-position normalization, config JSON builders/parsers, focus-policy toggles, subcategory allow-list policy, position-key detection, category filtering, and item-template/axis diff --git a/docs/prompt-pool-routing-map.md b/docs/prompt-pool-routing-map.md index 5dbc45d..12137e7 100644 --- a/docs/prompt-pool-routing-map.md +++ b/docs/prompt-pool-routing-map.md @@ -85,7 +85,7 @@ Core helper ownership: | `subject_context.py` | Row subject-context routing for single, couple, configured-cast, group, and layout subjects, combining appearance policy, cast metadata, and generator subject pools. | | `location_config.py` | Location/composition preset schemas, themed location packs, custom location/composition parsing, pool merge behavior, and location/composition config parsing. | | `row_location.py` | Built-in row location/composition config application, deterministic scene/composition choice, source metadata, and legacy prompt/caption rewrites. | -| `row_expression.py` | Row expression cleanup, expression intensity weighting, character-slot/cast expression override resolution, and per-character expression selection. | +| `row_expression.py` | Row expression cleanup, expression intensity weighting, character-slot/cast expression override resolution, per-character expression selection, and action-aware character-expression sanitizing. | | `row_pools.py` | Row scene/expression/pose/composition pool routing, category inheritance handling, runtime location/composition pool overrides, and generator fallback pools. | | `hardcore_position_config.py` | Hardcore position/action-filter choices, selected-position normalization, config JSON builders/parsers, focus-policy toggles, subcategory allow-list policy, position-key detection, and category/template/axis filtering. | | `pair_options.py` | Insta/OF option schema/defaults, softcore category/outfit/pose pools, partner outfit pools, clothing-continuity labels, negatives, hardcore cast count policy, and hardcore detail-density directives. | @@ -846,7 +846,7 @@ pair metadata through the core Python APIs, then verifies: | Wrong location | `categories/location_pools.json`, category `scene_pool`, `_scene_pool`. | | Location good but camera/location layout wrong | `_camera_scene_directive_for_context`, coworking adapter functions. | | Repeated desk/anchor in POV foreground | Coworking direction/distance/elevation helpers. | -| Wrong expression intensity | Character slot expression settings, `row_expression.py`, expression pools. | +| Wrong expression intensity or action-incompatible character expression | Character slot expression settings, `row_expression.py`, expression pools. | | Expression appears when disabled | `row_expression.disable_row_expression`, formatter expression extraction. | | Same hardcore action repeats | Hardcore filter config, `sexual_poses.json` weights, `hardcore_position_config.apply_hardcore_position_config_to_subcategory`. | | Hardcore interaction beat falls back to penetration/oral | `sexual_poses.json` interaction subcategory, `_role_graph`, and `krea_action_context.is_foreplay_text` / `krea_action_positions.hardcore_pose_anchor`. | diff --git a/prompt_builder.py b/prompt_builder.py index 89f3224..8e56d5c 100644 --- a/prompt_builder.py +++ b/prompt_builder.py @@ -1454,93 +1454,12 @@ def _sanitize_character_expression_text_for_action( item: Any, axis_values: Any = None, ) -> str: - text = str(expression_text or "").strip() - if not text: - return "" - context = " ".join( - str(part or "").lower() - for part in ( - role_graph, - item, - *((axis_values or {}).values() if isinstance(axis_values, dict) else ()), - ) + return row_expression_policy.sanitize_character_expression_text_for_action( + expression_text, + role_graph, + item, + axis_values, ) - woman_active_outercourse = ( - re.search(r"\bwoman [a-z]\b", context) - and re.search(r"\bman [a-z]\b", context) - and any( - term in context - for term in ( - "boobjob", - "titjob", - "breast sex", - "breasts tightly", - "testicle", - "balls-licking", - "balls licking", - "penis-licking", - "penis licking", - "handjob", - "hand job", - "footjob", - ) - ) - ) - woman_gives_oral = ( - re.search(r"\bwoman [a-z]\b", context) - and re.search(r"\bman [a-z]\b", context) - and any( - term in context - for term in ( - "takes man", - "penis in her mouth", - "mouth at penis level", - "fellatio", - "blowjob", - "deepthroat", - "penis sucking", - "lips wrapped", - ) - ) - ) - man_gives_oral = ( - re.search(r"\bwoman [a-z]\b", context) - and re.search(r"\bman [a-z]\b", context) - and any( - term in context - for term in ( - "mouth on her pussy", - "mouth on woman", - "mouth pressed to her pussy", - "cunnilingus", - "pussy licking", - "tongue on pussy", - ) - ) - ) - mouth_expression_terms = ("mouth", "oral", "tongue", "lips", "gagging", "saliva") - clauses = [clause.strip() for clause in text.split(";") if clause.strip()] - if woman_active_outercourse: - clauses = [clause for clause in clauses if not re.match(r"^Man [A-Z] has\b", clause)] - if woman_gives_oral: - clauses = [ - clause - for clause in clauses - if not ( - re.match(r"^Man [A-Z] has\b", clause) - and any(term in clause.lower() for term in mouth_expression_terms) - ) - ] - if man_gives_oral: - clauses = [ - clause - for clause in clauses - if not ( - re.match(r"^Woman [A-Z] has\b", clause) - and any(term in clause.lower() for term in mouth_expression_terms) - ) - ] - return "; ".join(clauses) def _descriptor_detail_for_subject(subject: Any, descriptor_detail: Any) -> str: diff --git a/row_expression.py b/row_expression.py index a29327f..f330ce6 100644 --- a/row_expression.py +++ b/row_expression.py @@ -302,3 +302,98 @@ def character_expression_entries( used.add(choice) expressions.append(f"{label} has {choice}") return expressions + + +def sanitize_character_expression_text_for_action( + expression_text: str, + role_graph: Any, + item: Any, + axis_values: Any = None, +) -> str: + text = str(expression_text or "").strip() + if not text: + return "" + context = " ".join( + str(part or "").lower() + for part in ( + role_graph, + item, + *((axis_values or {}).values() if isinstance(axis_values, dict) else ()), + ) + ) + woman_active_outercourse = ( + re.search(r"\bwoman [a-z]\b", context) + and re.search(r"\bman [a-z]\b", context) + and any( + term in context + for term in ( + "boobjob", + "titjob", + "breast sex", + "breasts tightly", + "testicle", + "balls-licking", + "balls licking", + "penis-licking", + "penis licking", + "handjob", + "hand job", + "footjob", + ) + ) + ) + woman_gives_oral = ( + re.search(r"\bwoman [a-z]\b", context) + and re.search(r"\bman [a-z]\b", context) + and any( + term in context + for term in ( + "takes man", + "penis in her mouth", + "mouth at penis level", + "fellatio", + "blowjob", + "deepthroat", + "penis sucking", + "lips wrapped", + ) + ) + ) + man_gives_oral = ( + re.search(r"\bwoman [a-z]\b", context) + and re.search(r"\bman [a-z]\b", context) + and any( + term in context + for term in ( + "mouth on her pussy", + "mouth on woman", + "mouth pressed to her pussy", + "cunnilingus", + "pussy licking", + "tongue on pussy", + ) + ) + ) + mouth_expression_terms = ("mouth", "oral", "tongue", "lips", "gagging", "saliva") + clauses = [clause.strip() for clause in text.split(";") if clause.strip()] + if woman_active_outercourse: + clauses = [clause for clause in clauses if not re.match(r"^Man [A-Z] has\b", clause)] + if woman_gives_oral: + clauses = [ + clause + for clause in clauses + if not ( + re.match(r"^Man [A-Z] has\b", clause) + and any(term in clause.lower() for term in mouth_expression_terms) + ) + ] + if man_gives_oral: + clauses = [ + clause + for clause in clauses + if not ( + re.match(r"^Woman [A-Z] has\b", clause) + and any(term in clause.lower() for term in mouth_expression_terms) + ) + ] + return "; ".join(clauses) diff --git a/tools/prompt_smoke.py b/tools/prompt_smoke.py index c42f9a3..233fe74 100644 --- a/tools/prompt_smoke.py +++ b/tools/prompt_smoke.py @@ -733,6 +733,25 @@ def smoke_row_expression_policy() -> None: == (None, "character_slots:disabled"), "Row expression cast override did not honor all-slot expression disable", ) + expression_text = "Woman A has steady focus; Man A has parted lips with saliva" + context_role = "Woman A performs a handjob while Man A stands close" + _expect( + pb._sanitize_character_expression_text_for_action(expression_text, context_role, "", {}) + == row_expression.sanitize_character_expression_text_for_action(expression_text, context_role, "", {}) + == "Woman A has steady focus", + "Row expression action sanitizer did not remove incompatible partner expression", + ) + reverse_context = "Man A has mouth pressed to her pussy while Woman A lies back" + _expect( + row_expression.sanitize_character_expression_text_for_action( + "Woman A has tongue out; Man A has focused mouth contact", + reverse_context, + "", + {}, + ) + == "Man A has focused mouth contact", + "Row expression action sanitizer did not remove incompatible visible-subject expression", + ) def smoke_row_item_policy() -> None: