diff --git a/docs/prompt-architecture-improvement-plan.md b/docs/prompt-architecture-improvement-plan.md index 01a94fd..7b716ed 100644 --- a/docs/prompt-architecture-improvement-plan.md +++ b/docs/prompt-architecture-improvement-plan.md @@ -61,6 +61,15 @@ route-specific owner. It also preserves ordinary words such as `composition` inside normal sentences; empty field-label cleanup is limited to standalone labels. +Shared hardcore phrase cleanup now has one home: + +- `hardcore_text_cleanup.py` + +It owns environment-anchor normalization used by both prompt generation and +Krea formatting, including malformed surface joins and bed/sheet/couch anchors +that should become model-neutral body-support language. It must stay +route-neutral: no Krea prose, no SDXL tags, and no category selection logic. + Current integration points: - `prompt_builder.build_prompt` @@ -95,8 +104,9 @@ Already isolated: - camera-scene prose and coworking composition adaptation live in `scene_camera_adapters.py`; `prompt_builder.py` still owns camera config parsing and row mutation. -- shared hardcore environment-anchor cleanup normalizes malformed pool joins - such as `on against a wall` before metadata reaches formatter routes. +- shared hardcore environment-anchor cleanup lives in + `hardcore_text_cleanup.py` and normalizes malformed pool joins before metadata + reaches formatter routes. ### Pair / Adapter Layer @@ -125,17 +135,21 @@ Owner: `krea_formatter.py`. Keep here: - Krea prose style; -- cast prose; - hardcore action sentence rewriting; - POV sentence rewriting; - clothing naturalization; - camera-scene preservation; - fallback text parsing. +Already isolated: + +- `krea_cast.py` owns cast descriptor parsing, cast prose, label joining, and + natural label replacement for formatter routes. + Improve later: - split semantic blocks into modules: - `krea_cast.py`, `krea_actions.py`, `krea_pov.py`, `krea_clothing.py`; + `krea_actions.py`, `krea_pov.py`, `krea_clothing.py`; - add route-level smoke fixtures for representative metadata rows; - make `_hardcore_action_sentence` dispatch by action family instead of long conditional chains. @@ -316,7 +330,8 @@ Medium-term: ## Recommended Next Passes -1. Split Krea action/POV/clothing helpers into separate modules. +1. Split Krea action/POV/clothing helpers into separate modules, using + `krea_cast.py` as the pattern for stable import aliases and smoke coverage. 2. Split `__init__.py` node classes by family after behavior is covered by smoke checks. 3. Add metadata fields such as `action_family` / `position_family` to reduce diff --git a/docs/prompt-pool-routing-map.md b/docs/prompt-pool-routing-map.md index a5b1b33..99afe5a 100644 --- a/docs/prompt-pool-routing-map.md +++ b/docs/prompt-pool-routing-map.md @@ -84,7 +84,7 @@ These recipes identify the intended road before editing prompt text. | --- | --- | --- | --- | | Keep character/location but change only sexual pose | `Global Seed` or fixed seed config -> builder/pair | Keep `person_seed` and `scene_seed` fixed; change `pose_seed` and usually `role_seed`; for hardcore categories check `content_seed_axis` | `sexual_poses.json`, `hardcore_position_config`, Krea `_hardcore_action_sentence` | | Generate a specific hardcore oral/blowjob scene | `Hardcore Position Pool` -> `Hardcore Action Filter` -> `Insta/OF Prompt Pair` or `Prompt Builder` | Use `focus=oral_only` or disable non-oral families; keep `allow_oral=true`; constrain position pool to kneeling/standing/oral variants when needed | `sexual_poses.json` oral subcategory/templates, `_apply_hardcore_position_config_to_subcategory`, `_hardcore_action_sentence` | -| Generate POV oral or POV penetration | `Man Slot` with POV presence -> `character_cast` -> pair/builder -> Krea2 formatter | POV man must be in the cast; use metadata into Krea2; normal camera directive is suppressed by POV | `_pov_hardcore_pose_sentence`, `_pov_action_phrase`, `_cast_prose` omit-label handling | +| Generate POV oral or POV penetration | `Man Slot` with POV presence -> `character_cast` -> pair/builder -> Krea2 formatter | POV man must be in the cast; use metadata into Krea2; normal camera directive is suppressed by POV | `_pov_hardcore_pose_sentence`, `_pov_action_phrase`, `krea_cast.cast_prose` omit-label handling | | Generate porn-scene interaction beats | `Hardcore Position Pool` -> `Hardcore Action Filter` -> pair/builder | Use `focus=interaction_only` for kissing/body worship/transitions/guidance/camera/watching/aftercare, or `focus=manual_only` for fingering/clit/manual stimulation; constrain keys such as `camera_showing`, `wrist_pinning`, `fingering`, `aftercare` | `sexual_poses.json` interaction/manual subcategories, `_role_graph`, Krea `_is_foreplay_text` / `_hardcore_action_sentence` | | Same woman, same room, softcore and hardcore outputs | `Character Slot/Profile` -> `Insta/OF Options` -> `Insta/OF Prompt Pair` | `continuity=same_creator_same_room`; set `softcore_cast` as needed; use pair metadata into formatter | `build_insta_of_pair`, `softcore_row`, `hardcore_row`, pair metadata fields | | Same cast in softcore and hardcore | Character slot chain -> `Insta/OF Options` | `softcore_cast=same_as_hardcore`; configure partner slots/outfits if needed | `_insta_of_partner_styling`, character slot clothing, pair Krea branch | @@ -715,6 +715,7 @@ pair metadata through the core Python APIs, then verifies: | Trigger missing in Krea2 fallback | `format_krea2_prompt` preserve-trigger fallback behavior. | | SDXL tags too weak/wrong style | `sdxl_formatter.py` presets and `_row_core_tags` / `_soft_tags` / `_hard_tags`. | | Duplicate punctuation, empty labels, repeated trigger, repeated tag item | `prompt_hygiene.py`, then the route-specific formatter if the repeated content is semantic. | +| Bed/sheet/couch or malformed surface wording leaks into hardcore prompts | `hardcore_text_cleanup.py`, then the relevant category pool/template. | | Saved profile does not match liked character | Profile save/load path and whether the saved input is row metadata or regenerated slot config. | | Accumulator preview behavior wrong | `loop_nodes.py` accumulator methods and `web/accumulator_preview.js`. | diff --git a/hardcore_text_cleanup.py b/hardcore_text_cleanup.py new file mode 100644 index 0000000..87e7789 --- /dev/null +++ b/hardcore_text_cleanup.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +import re +from typing import Any + + +HARDCORE_ENVIRONMENT_ANCHOR_REPLACEMENTS = ( + (r"\bon against a wall\b", "against a wall"), + (r"\bstacked bodies on the bed\b", "close body alignment"), + (r"\bstacked bodies with close body alignment\b", "close body alignment"), + (r"\boverhead tangled-body anal frame\b", "overhead rear-entry anal frame"), + (r"\btangled-body\b", "close-body"), + (r"\bthree bodies tangled on the bed\b", "three bodies tangled in close contact"), + (r"\ba triangle of bodies on the mattress\b", "a triangle of bodies in close contact"), + (r"\bbodies tangled on the sheets\b", "bodies tangled in close contact"), + (r"\bwet bodies tangled on sheets\b", "wet bodies tangled in close contact"), + (r"\bbody arched on rumpled sheets\b", "body arched with clear skin contact"), + (r"\bface-down ass-up on the mattress\b", "face-down ass-up position"), + (r"\bsitting on the edge of the bed\b", "sitting on a raised edge"), + (r"\blying at the bed edge with thighs open\b", "lying near a raised edge with thighs open"), + (r"\bedge[- ]of[- ]bed\b", "edge-supported"), + (r"\bbed[- ]edge\b", "raised edge"), + (r"\bedge of (?:the )?bed\b", "raised edge"), + (r"\bbed edge\b", "raised edge"), + (r"\bhands? braced on the bed\b", "hands braced beside the body"), + (r"\bone hand pressing into the mattress\b", "one hand braced beside the body"), + (r"\bone foot planted on the bed\b", "one foot planted for leverage"), + (r"\bfingers gripping sheets and skin\b", "fingers gripping skin"), + (r"\bfingers gripping sheets\b", "fingers gripping skin"), + (r"\bhands gripping sheets\b", "hands gripping skin"), + (r"\bone hand gripping the sheets\b", "one hand gripping skin"), + (r"\brumpled bed sheets\b", "wrinkled body-contact fabric"), + (r"\bwet sheets beneath the bodies\b", "visible wetness beneath the bodies"), + (r"\bsexual fluids on sheets\b", "sexual fluids visible on skin"), + (r"\bcum dripping onto sheets\b", "cum visible on skin"), + (r"\bfluid dripping onto sheets\b", "fluid visible on skin"), + (r"\bsquirting fluid on the sheets\b", "squirting fluid visible on skin"), + (r"\bsoft sheets\b", "soft fabric"), + (r"\bsilk sheets\b", "silk fabric"), + (r"\bsheets\b", "fabric"), + (r"\bmattress\b", "low support surface"), + (r"\ba low support surface\b", "a low body support"), + (r"\ba low mattress\b", "a low body support"), + (r"\ba wide couch\b", "a wide body support"), + (r"\bwide couch\b", "wide body support"), + (r"\bcouch\b", "body support"), + (r"\bsofa\b", "body support"), + (r"\bon the bed\b", "on a body support"), + (r"\bon a bed\b", "on a body support"), + (r"\bbedroom-floor\b", "floor-level"), + (r"\bbedroom floor\b", "floor-level"), +) + + +def _clean_inline(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 sanitize_hardcore_environment_anchors(value: Any) -> str: + text = _clean_inline(value) + if not text: + return "" + for pattern, replacement in HARDCORE_ENVIRONMENT_ANCHOR_REPLACEMENTS: + text = re.sub(pattern, replacement, text, flags=re.IGNORECASE) + text = re.sub(r"\s+,", ",", text) + text = re.sub(r",\s*,", ",", text) + text = re.sub(r"\s{2,}", " ", text) + return text.strip() + + +def sanitize_hardcore_axis_values(values: Any) -> dict[str, str]: + if not isinstance(values, dict): + return {} + return { + str(key): sanitize_hardcore_environment_anchors(value) + for key, value in values.items() + } diff --git a/krea_cast.py b/krea_cast.py new file mode 100644 index 0000000..3463d2d --- /dev/null +++ b/krea_cast.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +import re +from typing import Any + + +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 _with_indefinite_article(text: str) -> str: + text = _clean(text) + if not text or text.lower().startswith(("a ", "an ")): + return text + article = "an" if text[:1].lower() in "aeiou" else "a" + return f"{article} {text}" + + +def prompt_cast_descriptors(text: str) -> str: + return _clean(text).replace("Woman A / primary creator:", "Woman A:") + + +def cast_entries(text: str) -> list[tuple[str, str]]: + text = prompt_cast_descriptors(text) + entries: list[tuple[str, str]] = [] + for part in text.split(";"): + part = _clean(part) + match = re.match(r"^((?:Woman|Man) [A-Z]):\s*(.+)$", part) + if match: + entries.append((match.group(1), _clean(match.group(2)))) + return entries + + +def label_join(labels: list[str]) -> str: + labels = [_clean(label) for label in labels if _clean(label)] + if not labels: + return "the named adults" + if set(labels) == {"Woman A", "Man A"}: + return "the woman and man" + if len(labels) == 1: + if labels[0] == "Woman A": + return "the woman" + if labels[0] == "Man A": + return "the man" + return labels[0] + if len(labels) == 2: + return f"{labels[0]} and {labels[1]}" + return f"{', '.join(labels[:-1])}, and {labels[-1]}" + + +def natural_label_text(text: Any, labels: list[str]) -> str: + text = _clean(text) + if not text: + return "" + if set(labels) == {"Woman A", "Man A"}: + text = re.sub(r"\bWoman A\b", "the woman", text) + text = re.sub(r"\bMan A\b", "the man", text) + elif labels == ["Woman A"]: + text = re.sub(r"\bWoman A\b", "the woman", text) + elif labels == ["Man A"]: + text = re.sub(r"\bMan A\b", "the man", text) + text = re.sub( + r"(^|[.!?]\s+)(the woman|the man)\b", + lambda match: match.group(1) + match.group(2).capitalize(), + text, + flags=re.IGNORECASE, + ) + return text + + +def lowercase_for_inline_join(text: str) -> str: + return re.sub( + r"^(The woman|The man|The viewer|The named adults)\b", + lambda match: match.group(1).lower(), + _clean(text), + flags=re.IGNORECASE, + ) + + +def cast_prose( + text: str, + central_label: str = "Woman A", + omit_labels: list[str] | set[str] | tuple[str, ...] = (), +) -> tuple[str, list[str]]: + raw_entries = cast_entries(text) + omitted = set(omit_labels or []) + entries = [(label, descriptor) for label, descriptor in raw_entries if label not in omitted] + if raw_entries and not entries: + return "", [] + if not entries: + return (f"{central_label} is {_clean(text)}" if _clean(text) else "", []) + labels = [label for label, _descriptor in entries] + if labels == ["Woman A"]: + return _with_indefinite_article(entries[0][1]), labels + if labels == ["Man A"]: + return _with_indefinite_article(entries[0][1]), labels + if set(labels) == {"Woman A", "Man A"} and len(labels) == 2: + by_label = {label: descriptor for label, descriptor in entries} + return f"{_with_indefinite_article(by_label['Woman A'])} alongside {_with_indefinite_article(by_label['Man A'])}", labels + sentences = [] + for label, descriptor in entries: + sentences.append(f"{label} is {descriptor}.") + if central_label in labels: + sentences.append(f"{central_label} is the central subject.") + return " ".join(sentences), labels diff --git a/krea_formatter.py b/krea_formatter.py index 3c3f10e..d050d75 100644 --- a/krea_formatter.py +++ b/krea_formatter.py @@ -5,8 +5,30 @@ import re from typing import Any try: + from .hardcore_text_cleanup import ( + sanitize_hardcore_axis_values as _sanitize_hardcore_axis_values, + sanitize_hardcore_environment_anchors as _sanitize_hardcore_environment_anchors, + ) + from .krea_cast import ( + cast_prose as _cast_prose, + label_join as _label_join, + lowercase_for_inline_join as _lowercase_for_inline_join, + natural_label_text as _natural_label_text, + prompt_cast_descriptors as _prompt_cast_descriptors, + ) from .prompt_hygiene import sanitize_negative_text, sanitize_prose_text except ImportError: # Allows local smoke tests with `python -c`. + from hardcore_text_cleanup import ( + sanitize_hardcore_axis_values as _sanitize_hardcore_axis_values, + sanitize_hardcore_environment_anchors as _sanitize_hardcore_environment_anchors, + ) + from krea_cast import ( + cast_prose as _cast_prose, + label_join as _label_join, + lowercase_for_inline_join as _lowercase_for_inline_join, + natural_label_text as _natural_label_text, + prompt_cast_descriptors as _prompt_cast_descriptors, + ) from prompt_hygiene import sanitize_negative_text, sanitize_prose_text @@ -46,72 +68,6 @@ def _clean(value: Any) -> str: return text -HARDCORE_ENVIRONMENT_ANCHOR_REPLACEMENTS = ( - (r"\bon against a wall\b", "against a wall"), - (r"\bstacked bodies on the bed\b", "close body alignment"), - (r"\bstacked bodies with close body alignment\b", "close body alignment"), - (r"\boverhead tangled-body anal frame\b", "overhead rear-entry anal frame"), - (r"\btangled-body\b", "close-body"), - (r"\bthree bodies tangled on the bed\b", "three bodies tangled in close contact"), - (r"\ba triangle of bodies on the mattress\b", "a triangle of bodies in close contact"), - (r"\bbodies tangled on the sheets\b", "bodies tangled in close contact"), - (r"\bwet bodies tangled on sheets\b", "wet bodies tangled in close contact"), - (r"\bbody arched on rumpled sheets\b", "body arched with clear skin contact"), - (r"\bface-down ass-up on the mattress\b", "face-down ass-up position"), - (r"\bsitting on the edge of the bed\b", "sitting on a raised edge"), - (r"\blying at the bed edge with thighs open\b", "lying near a raised edge with thighs open"), - (r"\bedge[- ]of[- ]bed\b", "edge-supported"), - (r"\bbed[- ]edge\b", "raised edge"), - (r"\bedge of (?:the )?bed\b", "raised edge"), - (r"\bbed edge\b", "raised edge"), - (r"\bhands? braced on the bed\b", "hands braced beside the body"), - (r"\bone hand pressing into the mattress\b", "one hand braced beside the body"), - (r"\bone foot planted on the bed\b", "one foot planted for leverage"), - (r"\bfingers gripping sheets and skin\b", "fingers gripping skin"), - (r"\bfingers gripping sheets\b", "fingers gripping skin"), - (r"\bhands gripping sheets\b", "hands gripping skin"), - (r"\bone hand gripping the sheets\b", "one hand gripping skin"), - (r"\brumpled bed sheets\b", "wrinkled body-contact fabric"), - (r"\bwet sheets beneath the bodies\b", "visible wetness beneath the bodies"), - (r"\bsexual fluids on sheets\b", "sexual fluids visible on skin"), - (r"\bcum dripping onto sheets\b", "cum visible on skin"), - (r"\bfluid dripping onto sheets\b", "fluid visible on skin"), - (r"\bsquirting fluid on the sheets\b", "squirting fluid visible on skin"), - (r"\bsoft sheets\b", "soft fabric"), - (r"\bsilk sheets\b", "silk fabric"), - (r"\bsheets\b", "fabric"), - (r"\bmattress\b", "low support surface"), - (r"\ba low support surface\b", "a low body support"), - (r"\ba low mattress\b", "a low body support"), - (r"\ba wide couch\b", "a wide body support"), - (r"\bwide couch\b", "wide body support"), - (r"\bcouch\b", "body support"), - (r"\bsofa\b", "body support"), - (r"\bon the bed\b", "on a body support"), - (r"\bon a bed\b", "on a body support"), - (r"\bbedroom-floor\b", "floor-level"), - (r"\bbedroom floor\b", "floor-level"), -) - - -def _sanitize_hardcore_environment_anchors(value: Any) -> str: - text = _clean(value) - if not text: - return "" - for pattern, replacement in HARDCORE_ENVIRONMENT_ANCHOR_REPLACEMENTS: - text = re.sub(pattern, replacement, text, flags=re.IGNORECASE) - text = re.sub(r"\s+,", ",", text) - text = re.sub(r",\s*,", ",", text) - text = re.sub(r"\s{2,}", " ", text) - return text.strip() - - -def _sanitize_hardcore_axis_values(values: Any) -> dict[str, str]: - if not isinstance(values, dict): - return {} - return {str(key): _sanitize_hardcore_environment_anchors(value) for key, value in values.items()} - - def _is_false(value: Any) -> bool: if isinstance(value, bool): return value is False @@ -255,95 +211,6 @@ def _combine_negative(*parts: str) -> str: return ", ".join(cleaned) -def _prompt_cast_descriptors(text: str) -> str: - return _clean(text).replace("Woman A / primary creator:", "Woman A:") - - -def _cast_entries(text: str) -> list[tuple[str, str]]: - text = _prompt_cast_descriptors(text) - entries: list[tuple[str, str]] = [] - for part in text.split(";"): - part = _clean(part) - match = re.match(r"^((?:Woman|Man) [A-Z]):\s*(.+)$", part) - if match: - entries.append((match.group(1), _clean(match.group(2)))) - return entries - - -def _label_join(labels: list[str]) -> str: - labels = [_clean(label) for label in labels if _clean(label)] - if not labels: - return "the named adults" - if set(labels) == {"Woman A", "Man A"}: - return "the woman and man" - if len(labels) == 1: - if labels[0] == "Woman A": - return "the woman" - if labels[0] == "Man A": - return "the man" - return labels[0] - if len(labels) == 2: - return f"{labels[0]} and {labels[1]}" - return f"{', '.join(labels[:-1])}, and {labels[-1]}" - - -def _natural_label_text(text: Any, labels: list[str]) -> str: - text = _clean(text) - if not text: - return "" - if set(labels) == {"Woman A", "Man A"}: - text = re.sub(r"\bWoman A\b", "the woman", text) - text = re.sub(r"\bMan A\b", "the man", text) - elif labels == ["Woman A"]: - text = re.sub(r"\bWoman A\b", "the woman", text) - elif labels == ["Man A"]: - text = re.sub(r"\bMan A\b", "the man", text) - text = re.sub( - r"(^|[.!?]\s+)(the woman|the man)\b", - lambda match: match.group(1) + match.group(2).capitalize(), - text, - flags=re.IGNORECASE, - ) - return text - - -def _lowercase_for_inline_join(text: str) -> str: - return re.sub( - r"^(The woman|The man|The viewer|The named adults)\b", - lambda match: match.group(1).lower(), - _clean(text), - flags=re.IGNORECASE, - ) - - -def _cast_prose( - text: str, - central_label: str = "Woman A", - omit_labels: list[str] | set[str] | tuple[str, ...] = (), -) -> tuple[str, list[str]]: - raw_entries = _cast_entries(text) - omitted = set(omit_labels or []) - entries = [(label, descriptor) for label, descriptor in raw_entries if label not in omitted] - if raw_entries and not entries: - return "", [] - if not entries: - return (f"{central_label} is {_clean(text)}" if _clean(text) else "", []) - labels = [label for label, _descriptor in entries] - if labels == ["Woman A"]: - return _with_indefinite_article(entries[0][1]), labels - if labels == ["Man A"]: - return _with_indefinite_article(entries[0][1]), labels - if set(labels) == {"Woman A", "Man A"} and len(labels) == 2: - by_label = {label: descriptor for label, descriptor in entries} - return f"{_with_indefinite_article(by_label['Woman A'])} alongside {_with_indefinite_article(by_label['Man A'])}", labels - sentences = [] - for label, descriptor in entries: - sentences.append(f"{label} is {descriptor}.") - if central_label in labels: - sentences.append(f"{central_label} is the central subject.") - return " ".join(sentences), labels - - def _pov_labels_from_value(value: Any) -> list[str]: labels: list[str] = [] if isinstance(value, list): diff --git a/prompt_builder.py b/prompt_builder.py index a5f048b..4583819 100644 --- a/prompt_builder.py +++ b/prompt_builder.py @@ -11,6 +11,10 @@ from typing import Any, Callable try: from . import generate_prompt_batches as g from . import scene_camera_adapters + from .hardcore_text_cleanup import ( + sanitize_hardcore_axis_values as _sanitize_hardcore_axis_values, + sanitize_hardcore_environment_anchors as _sanitize_hardcore_environment_anchors, + ) from .prompt_hygiene import ( sanitize_caption_text, sanitize_negative_text, @@ -19,6 +23,10 @@ try: except ImportError: # Allows local smoke tests with `python -c`. import generate_prompt_batches as g import scene_camera_adapters + from hardcore_text_cleanup import ( + sanitize_hardcore_axis_values as _sanitize_hardcore_axis_values, + sanitize_hardcore_environment_anchors as _sanitize_hardcore_environment_anchors, + ) from prompt_hygiene import ( sanitize_caption_text, sanitize_negative_text, @@ -965,70 +973,6 @@ def _heuristic_cast_compatible(text: str, women_count: int, men_count: int) -> b return True -HARDCORE_ENVIRONMENT_ANCHOR_REPLACEMENTS = ( - (r"\bon against a wall\b", "against a wall"), - (r"\bstacked bodies on the bed\b", "close body alignment"), - (r"\bstacked bodies with close body alignment\b", "close body alignment"), - (r"\boverhead tangled-body anal frame\b", "overhead rear-entry anal frame"), - (r"\btangled-body\b", "close-body"), - (r"\bthree bodies tangled on the bed\b", "three bodies tangled in close contact"), - (r"\ba triangle of bodies on the mattress\b", "a triangle of bodies in close contact"), - (r"\bbodies tangled on the sheets\b", "bodies tangled in close contact"), - (r"\bwet bodies tangled on sheets\b", "wet bodies tangled in close contact"), - (r"\bbody arched on rumpled sheets\b", "body arched with clear skin contact"), - (r"\bface-down ass-up on the mattress\b", "face-down ass-up position"), - (r"\bsitting on the edge of the bed\b", "sitting on a raised edge"), - (r"\blying at the bed edge with thighs open\b", "lying near a raised edge with thighs open"), - (r"\bedge[- ]of[- ]bed\b", "edge-supported"), - (r"\bbed[- ]edge\b", "raised edge"), - (r"\bedge of (?:the )?bed\b", "raised edge"), - (r"\bbed edge\b", "raised edge"), - (r"\bhands? braced on the bed\b", "hands braced beside the body"), - (r"\bone hand pressing into the mattress\b", "one hand braced beside the body"), - (r"\bone foot planted on the bed\b", "one foot planted for leverage"), - (r"\bfingers gripping sheets and skin\b", "fingers gripping skin"), - (r"\bfingers gripping sheets\b", "fingers gripping skin"), - (r"\bhands gripping sheets\b", "hands gripping skin"), - (r"\bone hand gripping the sheets\b", "one hand gripping skin"), - (r"\brumpled bed sheets\b", "wrinkled body-contact fabric"), - (r"\bwet sheets beneath the bodies\b", "visible wetness beneath the bodies"), - (r"\bsexual fluids on sheets\b", "sexual fluids visible on skin"), - (r"\bcum dripping onto sheets\b", "cum visible on skin"), - (r"\bfluid dripping onto sheets\b", "fluid visible on skin"), - (r"\bsquirting fluid on the sheets\b", "squirting fluid visible on skin"), - (r"\bsoft sheets\b", "soft fabric"), - (r"\bsilk sheets\b", "silk fabric"), - (r"\bsheets\b", "fabric"), - (r"\bmattress\b", "low support surface"), - (r"\ba low support surface\b", "a low body support"), - (r"\ba low mattress\b", "a low body support"), - (r"\ba wide couch\b", "a wide body support"), - (r"\bwide couch\b", "wide body support"), - (r"\bcouch\b", "body support"), - (r"\bsofa\b", "body support"), - (r"\bon the bed\b", "on a body support"), - (r"\bon a bed\b", "on a body support"), - (r"\bbedroom-floor\b", "floor-level"), - (r"\bbedroom floor\b", "floor-level"), -) - - -def _sanitize_hardcore_environment_anchors(value: Any) -> str: - text = str(value or "") - if not text: - return "" - for pattern, replacement in HARDCORE_ENVIRONMENT_ANCHOR_REPLACEMENTS: - text = re.sub(pattern, replacement, text, flags=re.IGNORECASE) - text = re.sub(r"\s+,", ",", text) - text = re.sub(r",\s*,", ",", text) - text = re.sub(r"\s{2,}", " ", text) - return text.strip() - - -def _sanitize_hardcore_axis_values(values: dict[str, str]) -> dict[str, str]: - return {key: _sanitize_hardcore_environment_anchors(value) for key, value in values.items()} - - def _compatible_entry(entry: Any, women_count: int, men_count: int) -> bool: if not isinstance(entry, dict): return _heuristic_cast_compatible(_entry_text(entry), women_count, men_count)