diff --git a/docs/prompt-architecture-improvement-plan.md b/docs/prompt-architecture-improvement-plan.md index 959d548..0a180aa 100644 --- a/docs/prompt-architecture-improvement-plan.md +++ b/docs/prompt-architecture-improvement-plan.md @@ -105,6 +105,9 @@ Already isolated: - 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. +- 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. - camera-scene prose and coworking composition adaptation live in `scene_camera_adapters.py`; `prompt_builder.py` still owns camera config parsing and row mutation. diff --git a/docs/prompt-pool-routing-map.md b/docs/prompt-pool-routing-map.md index 0e0777e..b72e827 100644 --- a/docs/prompt-pool-routing-map.md +++ b/docs/prompt-pool-routing-map.md @@ -65,6 +65,7 @@ Core helper ownership: | Python module | What it owns | | --- | --- | | `hardcore_role_graphs.py` | Source role graph construction for hardcore configured-cast rows, including POV-aware interaction geometry. | +| `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_action_metadata.py` | Source action-family and position-family metadata used by Krea2, SDXL, and caption routes. | | `scene_camera_adapters.py` | Location-aware camera/scene prose such as coworking lounge camera layout. | @@ -733,6 +734,9 @@ pair metadata through the core Python APIs, then verifies: suffix; - POV outercourse routes keep constrained boobjob, testicle-sucking, penis-licking, handjob, and footjob geometry through Krea formatting; +- POV oral routes keep constrained kneeling, face-sitting, sixty-nine, + edge-supported, side-lying, and chair oral geometry through Krea formatting + without recursive viewer wording; - expression-disabled rows do not fall back to generated expression text. - static formatter metadata fixtures keep source-provided action families stable across Krea2 prose, SDXL tags, and natural captions even when raw item diff --git a/hardcore_role_graphs.py b/hardcore_role_graphs.py index 44d200e..0563e62 100644 --- a/hardcore_role_graphs.py +++ b/hardcore_role_graphs.py @@ -5,8 +5,10 @@ import re from typing import Any try: + from .hardcore_role_oral import build_oral_role_graph from .hardcore_role_outercourse import build_outercourse_role_graph except ImportError: # Allows local smoke tests with `python -c`. + from hardcore_role_oral import build_oral_role_graph from hardcore_role_outercourse import build_outercourse_role_graph @@ -53,7 +55,6 @@ def build_hardcore_role_graph( people = participants["people"] slug = str(subcategory.get("slug") or subcategory.get("name") or "").lower() item_text = " ".join((item_axis_values or {}).values()).lower() - pov_set = set(pov_labels or []) def any_person(exclude: set[str] | None = None) -> str: exclude = exclude or set() @@ -298,100 +299,6 @@ def build_hardcore_role_graph( return f"{woman} kneels forward with hips raised while {man} kneels behind her and thrusts his penis into her ass." return f"{woman} is on all fours with hips raised while {man} is positioned behind her and thrusts his penis into her ass." - def oral_position_graph(woman: str, man: str) -> str: - position_text = str((item_axis_values or {}).get("position") or "").lower() - text = " ".join( - str(part or "").lower() - for part in ( - item_text, - *((item_axis_values or {}).values()), - ) - ) - man_is_pov = man in pov_set - woman_gives = any( - term in text - for term in ( - "fellatio", - "blowjob", - "deepthroat", - "penis sucking", - "penis in mouth", - "penis in her mouth", - "mouth stretched around a penis", - "lips wrapped", - ) - ) - man_gives = any( - term in text - for term in ( - "cunnilingus", - "pussy licking", - "tongue on pussy", - "mouth on pussy", - "pussy and tongue", - "face-sitting", - "tongue contact clearly visible", - ) - ) - if "mouth on genitals" in text and not woman_gives and not man_gives: - if any(term in text for term in ("face-sitting", "reclining", "straddled", "spread-leg", "open thighs")): - man_gives = True - else: - woman_gives = True - - if "sixty-nine" in position_text or ("sixty-nine" in text and not position_text): - return f"{woman} and {man} lie head-to-hips in a sixty-nine position, with {woman}'s mouth on {man}'s penis and {man}'s mouth on {woman}'s pussy." - if "face-sitting" in position_text or ("face-sitting" in text and not position_text): - if man_is_pov: - return ( - f"{woman} is above the POV camera, straddling the POV viewer's face with thighs on both sides of his head, " - "pussy directly over the POV viewer's mouth for close first-person underview tongue contact." - ) - return f"{man} lies on his back while {woman} straddles his face with her thighs around his head and {man}'s mouth pressed to her pussy." - if "straddled oral" in position_text or ("straddled oral" in text and not position_text): - if woman_gives and not man_gives: - return f"{man} straddles forward near {woman}'s face while {woman} kneels below him with her mouth on his penis." - return f"{woman} straddles above {man}'s face with her thighs framing his head while {man}'s mouth stays pressed to her pussy." - if "side-lying oral" in position_text or ("side-lying oral" in text and not position_text): - if woman_gives and not man_gives: - return f"{man} lies on his side with hips angled toward {woman} while {woman} lies beside his thighs and takes his penis in her mouth." - return f"{woman} lies on her side with her top thigh lifted while {man} lies beside her hips with his mouth pressed to her pussy." - if ( - "edge-of-bed oral" in position_text - or "edge of bed oral" in position_text - or "edge-supported oral" in position_text - or (("edge-of-bed oral" in text or "edge of bed oral" in text or "edge-supported oral" in text) and not position_text) - ): - if woman_gives and not man_gives: - return f"{man} sits at a raised edge with legs apart while {woman} kneels between his thighs and takes his penis in her mouth." - return f"{woman} lies at a raised edge with thighs open while {man} kneels between her legs with his mouth on her pussy." - if "standing oral" in position_text or ("standing oral" in text and not position_text): - if man_gives and not woman_gives: - return f"{woman} stands braced with one thigh lifted while {man} kneels between her legs with his mouth on her pussy." - return f"{man} stands with hips forward while {woman} kneels in front of him at hip height and takes his penis in her mouth." - if "chair oral" in position_text or ("chair oral" in text and not position_text): - if man_gives and not woman_gives: - return f"{woman} sits in a chair with thighs open while {man} kneels between her legs with his mouth pressed to her pussy." - return f"{man} sits in a chair with legs apart while {woman} kneels between his thighs and takes his penis in her mouth." - if ( - "reclining cunnilingus" in position_text - or "spread-leg oral" in position_text - or (("reclining cunnilingus" in text or "spread-leg oral" in text) and not position_text) - ): - if woman_gives and not man_gives: - return f"{man} reclines with legs apart while {woman} kneels between his thighs and takes his penis in her mouth." - return f"{woman} reclines on her back with thighs spread while {man} kneels between her legs with his mouth on her pussy." - if "kneeling oral" in position_text or ("kneeling oral" in text and not position_text): - if man_gives and not woman_gives: - return f"{woman} kneels with thighs parted and hips angled forward while {man} kneels in front of her with his mouth on her pussy." - return ( - f"{woman} kneels in front of {man}'s penis while {man} stands over her; " - f"{woman} takes {man}'s penis in her mouth with saliva dripping on the penis as {man} looks down toward her." - ) - if man_gives and not woman_gives: - return f"{woman} lies on her back with thighs open while {man} kneels between her legs with his mouth pressed to her pussy." - return f"{woman} kneels in front of {man}'s hips and takes his penis in her mouth while {man} keeps his hips aligned with her face." - if people_count == 1: solo = people[0] if women_count == 1: @@ -480,7 +387,7 @@ def build_hardcore_role_graph( elif "outercourse" in slug: graph = build_outercourse_role_graph(woman, man, item_text, item_axis_values, pov_labels) elif "oral" in slug: - graph = oral_position_graph(woman, man) + graph = build_oral_role_graph(woman, man, item_text, item_axis_values, pov_labels) elif "anal" in slug or "double" in slug: if "double" in item_text or "toy" in item_text: if people_count >= 3: diff --git a/hardcore_role_oral.py b/hardcore_role_oral.py new file mode 100644 index 0000000..2ff612d --- /dev/null +++ b/hardcore_role_oral.py @@ -0,0 +1,153 @@ +from __future__ import annotations + +from typing import Any + + +def _context_text(item_text: str, item_axis_values: dict[str, Any] | None) -> str: + return " ".join( + str(part or "").lower() + for part in ( + item_text, + *((item_axis_values or {}).values()), + ) + ) + + +def _oral_direction(text: str) -> tuple[bool, bool]: + woman_gives = any( + term in text + for term in ( + "fellatio", + "blowjob", + "deepthroat", + "penis sucking", + "penis in mouth", + "penis in her mouth", + "mouth stretched around a penis", + "lips wrapped", + ) + ) + man_gives = any( + term in text + for term in ( + "cunnilingus", + "pussy licking", + "tongue on pussy", + "mouth on pussy", + "pussy and tongue", + "face-sitting", + "tongue contact clearly visible", + ) + ) + if "mouth on genitals" in text and not woman_gives and not man_gives: + if any(term in text for term in ("face-sitting", "reclining", "straddled", "spread-leg", "open thighs")): + man_gives = True + else: + woman_gives = True + return woman_gives, man_gives + + +def build_oral_role_graph( + woman: str, + man: str, + item_text: str, + item_axis_values: dict[str, Any] | None = None, + pov_labels: list[str] | None = None, +) -> str: + position_text = str((item_axis_values or {}).get("position") or "").lower() + text = _context_text(item_text, item_axis_values) + man_is_pov = man in set(pov_labels or []) + woman_gives, man_gives = _oral_direction(text) + + if "sixty-nine" in position_text or ("sixty-nine" in text and not position_text): + if man_is_pov: + return ( + f"{woman} and the viewer lie head-to-hips in a sixty-nine position, " + f"with {woman}'s mouth on the viewer's penis and the viewer's mouth on {woman}'s pussy." + ) + return f"{woman} and {man} lie head-to-hips in a sixty-nine position, with {woman}'s mouth on {man}'s penis and {man}'s mouth on {woman}'s pussy." + if "face-sitting" in position_text or ("face-sitting" in text and not position_text): + if man_is_pov: + return ( + f"{woman} is above the POV camera, straddling the viewer's face with thighs on both sides of his head, " + "pussy directly over the viewer's mouth for close first-person underview tongue contact." + ) + return f"{man} lies on his back while {woman} straddles his face with her thighs around his head and {man}'s mouth pressed to her pussy." + if "straddled oral" in position_text or ("straddled oral" in text and not position_text): + if woman_gives and not man_gives: + if man_is_pov: + return f"The viewer straddles forward near {woman}'s face while {woman} kneels below him with her mouth on his penis." + return f"{man} straddles forward near {woman}'s face while {woman} kneels below him with her mouth on his penis." + if man_is_pov: + return f"{woman} straddles above the viewer's face with her thighs framing his head while the viewer's mouth stays pressed to her pussy." + return f"{woman} straddles above {man}'s face with her thighs framing his head while {man}'s mouth stays pressed to her pussy." + if "side-lying oral" in position_text or ("side-lying oral" in text and not position_text): + if woman_gives and not man_gives: + if man_is_pov: + return f"The viewer lies on his side with hips angled toward {woman} while {woman} lies beside his thighs and takes the viewer's penis in her mouth." + return f"{man} lies on his side with hips angled toward {woman} while {woman} lies beside his thighs and takes his penis in her mouth." + if man_is_pov: + return f"{woman} lies on her side with her top thigh lifted while the viewer lies beside her hips with his mouth pressed to her pussy." + return f"{woman} lies on her side with her top thigh lifted while {man} lies beside her hips with his mouth pressed to her pussy." + if ( + "edge-of-bed oral" in position_text + or "edge of bed oral" in position_text + or "edge-supported oral" in position_text + or (("edge-of-bed oral" in text or "edge of bed oral" in text or "edge-supported oral" in text) and not position_text) + ): + if woman_gives and not man_gives: + if man_is_pov: + return f"The viewer sits at a raised edge with legs apart while {woman} kneels between his thighs and takes the viewer's penis in her mouth." + return f"{man} sits at a raised edge with legs apart while {woman} kneels between his thighs and takes his penis in her mouth." + if man_is_pov: + return f"{woman} lies at a raised edge with thighs open while the viewer kneels between her legs with his mouth on her pussy." + return f"{woman} lies at a raised edge with thighs open while {man} kneels between her legs with his mouth on her pussy." + if "standing oral" in position_text or ("standing oral" in text and not position_text): + if man_gives and not woman_gives: + if man_is_pov: + return f"{woman} stands braced with one thigh lifted while the viewer kneels between her legs with his mouth on her pussy." + return f"{woman} stands braced with one thigh lifted while {man} kneels between her legs with his mouth on her pussy." + if man_is_pov: + return f"The viewer stands with hips forward while {woman} kneels in front of him at hip height and takes the viewer's penis in her mouth." + return f"{man} stands with hips forward while {woman} kneels in front of him at hip height and takes his penis in her mouth." + if "chair oral" in position_text or ("chair oral" in text and not position_text): + if man_gives and not woman_gives: + if man_is_pov: + return f"{woman} sits in a chair with thighs open while the viewer kneels between her legs with his mouth pressed to her pussy." + return f"{woman} sits in a chair with thighs open while {man} kneels between her legs with his mouth pressed to her pussy." + if man_is_pov: + return f"The viewer sits in a chair with legs apart while {woman} kneels between his thighs and takes the viewer's penis in her mouth." + return f"{man} sits in a chair with legs apart while {woman} kneels between his thighs and takes his penis in her mouth." + if ( + "reclining cunnilingus" in position_text + or "spread-leg oral" in position_text + or (("reclining cunnilingus" in text or "spread-leg oral" in text) and not position_text) + ): + if woman_gives and not man_gives: + if man_is_pov: + return f"The viewer reclines with legs apart while {woman} kneels between his thighs and takes the viewer's penis in her mouth." + return f"{man} reclines with legs apart while {woman} kneels between his thighs and takes his penis in her mouth." + if man_is_pov: + return f"{woman} reclines on her back with thighs spread while the viewer kneels between her legs with his mouth on her pussy." + return f"{woman} reclines on her back with thighs spread while {man} kneels between her legs with his mouth on her pussy." + if "kneeling oral" in position_text or ("kneeling oral" in text and not position_text): + if man_gives and not woman_gives: + if man_is_pov: + return f"{woman} kneels with thighs parted and hips angled forward while the viewer kneels in front of her with his mouth on her pussy." + return f"{woman} kneels with thighs parted and hips angled forward while {man} kneels in front of her with his mouth on her pussy." + if man_is_pov: + return ( + f"{woman} kneels in front of the viewer's penis while he stands over her; " + f"{woman} takes the viewer's penis in her mouth with saliva dripping on the penis as he looks down toward her." + ) + return ( + f"{woman} kneels in front of {man}'s penis while {man} stands over her; " + f"{woman} takes {man}'s penis in her mouth with saliva dripping on the penis as {man} looks down toward her." + ) + if man_gives and not woman_gives: + if man_is_pov: + return f"{woman} lies on her back with thighs open while the viewer kneels between her legs with his mouth pressed to her pussy." + return f"{woman} lies on her back with thighs open while {man} kneels between her legs with his mouth pressed to her pussy." + if man_is_pov: + return f"{woman} kneels in front of the viewer's hips and takes the viewer's penis in her mouth while he keeps his hips aligned with her face." + return f"{woman} kneels in front of {man}'s hips and takes his penis in her mouth while {man} keeps his hips aligned with her face." diff --git a/tools/prompt_smoke.py b/tools/prompt_smoke.py index 7fd1834..c05add3 100644 --- a/tools/prompt_smoke.py +++ b/tools/prompt_smoke.py @@ -802,6 +802,89 @@ def smoke_pov_outercourse_position_routes() -> None: _expect(term in prompt, f"{name} Krea prompt missing {term!r}: {prompt}") +def smoke_pov_oral_position_routes() -> None: + cases = [ + ( + "pov_oral_kneeling", + "kneeling", + ("viewer's penis", "takes the viewer's penis in her mouth"), + ("takes the viewer's penis in her mouth", "viewer stands over her"), + ), + ( + "pov_oral_face_sitting", + "face_sitting", + ("straddling the viewer's face", "pussy directly over the viewer's mouth"), + ("straddling the viewer's face", "tongue contact visible"), + ), + ( + "pov_oral_sixty_nine", + "sixty_nine", + ("head-to-hips", "viewer's mouth on woman a's pussy"), + ("head-to-hips", "viewer's mouth on the woman's pussy"), + ), + ( + "pov_oral_edge_supported", + "edge_supported", + ("raised edge with thighs open", "viewer kneels between her legs"), + ("raised edge with thighs open", "viewer kneels between her legs"), + ), + ( + "pov_oral_side_lying", + "side_lying", + ("woman a lies on her side", "viewer lies beside her hips"), + ("woman lies on her side", "viewer lies beside her hips"), + ), + ( + "pov_oral_chair", + "chair_oral", + ("viewer sits in a chair", "kneels between his thighs"), + ("viewer sits in a chair", "kneels between the viewer's thighs"), + ), + ] + for offset, (name, position_key, role_terms, krea_terms) in enumerate(cases, start=3701): + pair = pb.build_insta_of_pair( + row_number=1, + start_index=1, + seed=offset, + ethnicity="any", + figure="random", + no_plus_women=False, + no_black=False, + trigger=Trigger, + prepend_trigger_to_prompt=True, + options_json=_insta_options( + softcore_camera_mode="from_camera_config", + hardcore_camera_mode="from_camera_config", + camera_detail="compact", + ), + character_cast=_character_cast(pov_man=True), + hardcore_position_config=_position_filter("oral_only", "oral", [position_key]), + location_config=_coworking_location_config(), + hardcore_camera_config=_orbit_camera( + horizontal_angle=45, + vertical_angle=0, + zoom=7.5, + subject_focus="action", + ), + ) + _expect_pair(pair, name) + hard_row = pair.get("hardcore_row") or {} + _expect(hard_row.get("action_family") == "oral", f"{name} action_family should be oral") + _expect(hard_row.get("position_family") == "oral", f"{name} position_family should be oral") + _expect(position_key in (hard_row.get("position_keys") or []), f"{name} lost position key {position_key!r}") + role_graph = _expect_text(f"{name}.source_role_graph", hard_row.get("source_role_graph"), 40).lower() + for term in role_terms: + _expect(term in role_graph, f"{name} role graph missing {term!r}: {role_graph}") + krea = krea_formatter.format_krea2_prompt("", metadata_json=_json(pair), target="hardcore") + prompt = _expect_text(f"{name}.krea_prompt", krea.get("krea_prompt"), 60).lower() + _expect("metadata" in krea.get("method", ""), f"{name}.krea did not use metadata") + _expect("viewer" in prompt and "first-person" in prompt, f"{name} Krea prompt lost POV wording") + _expect("viewer lies on the viewer" not in prompt, f"{name} Krea prompt kept recursive POV wording: {prompt}") + _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}") + + def smoke_no_expression_fallback() -> None: cast = pb.build_character_slot_json( subject_type="woman", @@ -934,6 +1017,7 @@ SMOKE_CASES: list[tuple[str, Callable[[], None]]] = [ ("pov_camera_scene", smoke_pov_camera_scene), ("krea_pov_penetration_route", smoke_krea_pov_penetration_route), ("pov_outercourse_position_routes", smoke_pov_outercourse_position_routes), + ("pov_oral_position_routes", smoke_pov_oral_position_routes), ("expression_disabled", smoke_no_expression_fallback), ("formatter_metadata_fixtures", smoke_formatter_metadata_fixtures), ]