diff --git a/docs/prompt-architecture-improvement-plan.md b/docs/prompt-architecture-improvement-plan.md index 8212770..1d12b05 100644 --- a/docs/prompt-architecture-improvement-plan.md +++ b/docs/prompt-architecture-improvement-plan.md @@ -107,6 +107,9 @@ Already isolated: - shared hardcore environment-anchor cleanup lives in `hardcore_text_cleanup.py` and normalizes malformed pool joins before metadata reaches formatter routes. +- shared hardcore action metadata lives in `hardcore_action_metadata.py`; custom + rows now emit `action_family`, `position_family`, `position_key`, and + `position_keys` so formatter routing and debugging do less keyword guessing. ### Pair / Adapter Layer @@ -148,6 +151,8 @@ Already isolated: - `krea_action_context.py` owns shared action-family predicates, axis context text, climax detection, and detail-density normalization used by action and POV formatter routes. +- `hardcore_action_metadata.py` owns shared action-family constants, + normalization, and inference used by the builder and Krea formatter route. - `krea_pov.py` owns POV labels, POV label filtering, and POV camera/composition support text. - `krea_detail.py` owns generic detail-clause splitting, deduping, joining, and @@ -166,8 +171,8 @@ Already isolated: Improve later: -- add metadata fields such as `action_family` / `position_family` to reduce - keyword guessing in hardcore formatter dispatch; +- extend SDXL and caption routes to optionally consume `action_family` / + `position_family` when ordering tags or caption clauses; - add route-level smoke fixtures for representative metadata rows; ### SDXL Formatter Path @@ -346,8 +351,8 @@ Medium-term: ## Recommended Next Passes -1. Add metadata fields such as `action_family` / `position_family` to reduce - keyword guessing in hardcore filters and formatter dispatch. +1. Extend SDXL and caption routes to optionally consume `action_family` / + `position_family` when ordering tags or caption clauses. 2. Split `__init__.py` node classes by family after behavior is covered by smoke checks. 3. Add route-level smoke fixtures for representative Krea/SDXL/caption metadata diff --git a/docs/prompt-pool-routing-map.md b/docs/prompt-pool-routing-map.md index 0f33897..fc93cd8 100644 --- a/docs/prompt-pool-routing-map.md +++ b/docs/prompt-pool-routing-map.md @@ -397,6 +397,9 @@ plain prompt text. When debugging, inspect these fields before editing pools. | `content_seed_axis` | `_build_custom_row` | Debug | Shows whether the item/action was driven by `content` or `pose`. Critical for hardcore pose categories. | | `item` | `_compose_item` or Insta override | Krea/SDXL/Naturalizer | Clothing item, category item, or sexual scene/action text. | | `item_axis_values` | `_compose_item` | Krea hardcore rewrite, SDXL tags | Filled template axes such as position/action/detail values. | +| `action_family` | `hardcore_action_metadata.source_hardcore_action_family` | Krea hardcore rewrite, debug | Source-aware formatter semantic family such as `foreplay`, `outercourse`, `oral`, `penetration`, `toy_double`, or `climax`. | +| `position_family` | `_hardcore_source_position_family` | Debug/filtering | Source/UI hardcore family selected by subcategory, such as `manual`, `interaction`, `oral`, `anal`, or `climax`. | +| `position_key`, `position_keys` | `_hardcore_position_keys` | Debug/future filters | Concrete position tokens inferred from axes and role text, such as `kneeling`, `doggy`, `boobjob`, or `open_thighs`. | | `custom_item`, `item_label` | Category/pair route | Formatters and debug | Label/name for item route. | | `role_graph` | `_role_graph`, POV adapter | Krea/Naturalizer | Choreography/action relationship text after POV adaptation. | | `source_role_graph` | `_role_graph` before POV rewrite | Krea hardcore rewrite | Raw action graph used to infer position and contact. | @@ -564,6 +567,7 @@ Key Krea2 ownership: - Cast descriptor naturalization: `krea_cast.cast_prose`, `krea_cast.natural_label_text`. +- Shared action-family metadata: `hardcore_action_metadata.py`. - Action context and family predicates: `krea_action_context.py`. - Non-POV pose anchors and arrangements: `krea_action_positions.py`. - Non-climax item/detail cleanup: `krea_action_details.py`. @@ -751,13 +755,15 @@ Use these traces to narrow a problem in one pass. ### Hardcore action keeps selecting the same family 1. Check metadata `main_category`, `subcategory`, `content_seed_axis`, - `hardcore_position_config`, `item`, `role_graph`, and `item_axis_values`. + `action_family`, `position_family`, `position_key`, `hardcore_position_config`, + `item`, `role_graph`, and `item_axis_values`. 2. If `hardcore_position_config` disabled most families, the repeated action may be the only compatible pool left. 3. Inspect `categories/sexual_poses.json` for the selected subcategory, `item_templates`, `axes`, and `weight`. 4. If raw `item` differs but Krea output looks identical, inspect - `krea_action_context.py` family predicates first, then + `hardcore_action_metadata.py` action-family metadata first, then + `krea_action_context.py` family predicates, `krea_action_positions.py` pose anchors/arrangements, `krea_action_details.py` item/detail cleanup, `krea_action_climax.py` climax cleanup, `krea_action_dispatch.py` family routing, and diff --git a/hardcore_action_metadata.py b/hardcore_action_metadata.py new file mode 100644 index 0000000..5f0521b --- /dev/null +++ b/hardcore_action_metadata.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +from typing import Any + +try: + from .krea_action_context import ( + axis_values_text, + is_climax_text, + is_foreplay_text, + is_oral_text, + is_outercourse_text, + is_toy_assisted_double_text, + is_vaginal_penetration_text, + ) +except ImportError: # Allows local smoke tests with `python -c`. + from krea_action_context import ( + axis_values_text, + is_climax_text, + is_foreplay_text, + is_oral_text, + is_outercourse_text, + is_toy_assisted_double_text, + is_vaginal_penetration_text, + ) + + +ACTION_CLIMAX = "climax" +ACTION_FOREPLAY = "foreplay" +ACTION_OUTERCOURSE = "outercourse" +ACTION_ORAL = "oral" +ACTION_PENETRATION = "penetration" +ACTION_TOY_DOUBLE = "toy_double" +ACTION_DEFAULT = "default" + +HARDCORE_ACTION_FAMILY_CHOICES = { + ACTION_CLIMAX, + ACTION_FOREPLAY, + ACTION_OUTERCOURSE, + ACTION_ORAL, + ACTION_PENETRATION, + ACTION_TOY_DOUBLE, + ACTION_DEFAULT, +} + + +def normalize_hardcore_action_family(value: Any, default: str = "") -> str: + text = str(value or "").strip().lower() + if text == "penetrative": + text = ACTION_PENETRATION + return text if text in HARDCORE_ACTION_FAMILY_CHOICES else default + + +def infer_hardcore_action_family( + role_graph: str, + hard_item: str, + composition: str = "", + axis_values: Any = None, + *, + is_climax: bool | None = None, +) -> str: + axis_text = axis_values_text(axis_values) + if is_climax is None: + is_climax = is_climax_text(role_graph, hard_item, composition, axis_text) + if is_climax: + return ACTION_CLIMAX + if is_foreplay_text(role_graph, hard_item, composition, axis_text): + return ACTION_FOREPLAY + if is_outercourse_text(role_graph, hard_item, composition, axis_text): + return ACTION_OUTERCOURSE + if is_oral_text(role_graph, hard_item, composition, axis_text): + return ACTION_ORAL + if is_vaginal_penetration_text(role_graph, hard_item, composition, axis_text): + return ACTION_PENETRATION + if is_toy_assisted_double_text(role_graph, hard_item, composition, axis_text): + return ACTION_TOY_DOUBLE + return ACTION_DEFAULT + + +def source_hardcore_action_family( + source_family: Any, + role_graph: str, + hard_item: str, + composition: str = "", + axis_values: Any = None, +) -> str: + inferred = infer_hardcore_action_family(role_graph, hard_item, composition, axis_values) + if inferred in (ACTION_CLIMAX, ACTION_TOY_DOUBLE): + return inferred + family = str(source_family or "").strip().lower() + source_mapping = { + "penetrative": ACTION_PENETRATION, + "foreplay": ACTION_FOREPLAY, + "interaction": ACTION_FOREPLAY, + "manual": ACTION_FOREPLAY, + "oral": ACTION_ORAL, + "outercourse": ACTION_OUTERCOURSE, + "climax": ACTION_CLIMAX, + } + if family == "anal": + return ACTION_DEFAULT + return source_mapping.get(family, inferred) diff --git a/krea_action_dispatch.py b/krea_action_dispatch.py index db6642b..3ea5ab3 100644 --- a/krea_action_dispatch.py +++ b/krea_action_dispatch.py @@ -8,13 +8,19 @@ try: from .krea_action_context import ( axis_values_text, is_climax_text, - is_foreplay_text, - is_oral_text, - is_outercourse_text, is_toy_assisted_double_text, - is_vaginal_penetration_text, normalize_hardcore_detail_density, ) + from .hardcore_action_metadata import ( + ACTION_CLIMAX, + ACTION_FOREPLAY, + ACTION_ORAL, + ACTION_OUTERCOURSE, + ACTION_PENETRATION, + ACTION_TOY_DOUBLE, + infer_hardcore_action_family, + normalize_hardcore_action_family, + ) from .krea_detail import limit_detail_for_density from .krea_action_positions import hardcore_pose_anchor from .krea_action_details import ( @@ -31,13 +37,19 @@ except ImportError: # Allows local smoke tests with `python -c`. from krea_action_context import ( axis_values_text, is_climax_text, - is_foreplay_text, - is_oral_text, - is_outercourse_text, is_toy_assisted_double_text, - is_vaginal_penetration_text, normalize_hardcore_detail_density, ) + from hardcore_action_metadata import ( + ACTION_CLIMAX, + ACTION_FOREPLAY, + ACTION_ORAL, + ACTION_OUTERCOURSE, + ACTION_PENETRATION, + ACTION_TOY_DOUBLE, + infer_hardcore_action_family, + normalize_hardcore_action_family, + ) from krea_detail import limit_detail_for_density from krea_action_positions import hardcore_pose_anchor from krea_action_details import ( @@ -52,15 +64,6 @@ except ImportError: # Allows local smoke tests with `python -c`. from krea_action_climax import climax_role_graph, dedupe_climax_detail -ACTION_CLIMAX = "climax" -ACTION_FOREPLAY = "foreplay" -ACTION_OUTERCOURSE = "outercourse" -ACTION_ORAL = "oral" -ACTION_PENETRATION = "penetration" -ACTION_TOY_DOUBLE = "toy_double" -ACTION_DEFAULT = "default" - - @dataclass(frozen=True) class HardcoreActionParts: family: str @@ -129,32 +132,6 @@ def normalize_toy_double_role_graph(role_graph: str) -> str: ) -def hardcore_action_family( - role_graph: str, - hard_item: str, - composition: str = "", - axis_values: Any = None, - *, - is_climax: bool | None = None, -) -> str: - axis_text = axis_values_text(axis_values) - if is_climax is None: - is_climax = is_climax_text(role_graph, hard_item, composition, axis_text) - if is_climax: - return ACTION_CLIMAX - if is_foreplay_text(role_graph, hard_item, composition, axis_text): - return ACTION_FOREPLAY - if is_outercourse_text(role_graph, hard_item, composition, axis_text): - return ACTION_OUTERCOURSE - if is_oral_text(role_graph, hard_item, composition, axis_text): - return ACTION_ORAL - if is_vaginal_penetration_text(role_graph, hard_item, composition, axis_text): - return ACTION_PENETRATION - if is_toy_assisted_double_text(role_graph, hard_item, composition, axis_text): - return ACTION_TOY_DOUBLE - return ACTION_DEFAULT - - def action_detail_for_family( family: str, detail: str, @@ -194,18 +171,20 @@ def resolve_hardcore_action_parts( composition: str = "", axis_values: Any = None, detail_density: str = "balanced", + action_family: Any = "", ) -> HardcoreActionParts: detail_density = normalize_hardcore_detail_density(detail_density) role_graph = normalize_hardcore_role_graph(role_graph) hard_item = _clean(hard_item).rstrip(".") axis_text = axis_values_text(axis_values) - is_climax = is_climax_text(role_graph, hard_item, composition, axis_text) + forced_family = normalize_hardcore_action_family(action_family) + is_climax = forced_family == ACTION_CLIMAX or is_climax_text(role_graph, hard_item, composition, axis_text) if is_climax: role_graph = climax_role_graph(role_graph, hard_item, axis_values) detail = hardcore_item_detail(hard_item) anchor = hardcore_pose_anchor(role_graph, hard_item, composition, axis_values) - family = hardcore_action_family(role_graph, hard_item, composition, axis_values, is_climax=is_climax) + family = forced_family or infer_hardcore_action_family(role_graph, hard_item, composition, axis_values, is_climax=is_climax) if is_toy_assisted_double_text(role_graph, hard_item, composition, axis_text): role_graph = normalize_toy_double_role_graph(role_graph) diff --git a/krea_actions.py b/krea_actions.py index ab80cd1..eb9680d 100644 --- a/krea_actions.py +++ b/krea_actions.py @@ -44,8 +44,9 @@ def hardcore_action_sentence( composition: str = "", axis_values: Any = None, detail_density: str = "balanced", + action_family: Any = "", ) -> str: - parts = resolve_hardcore_action_parts(role_graph, hard_item, composition, axis_values, detail_density) + parts = resolve_hardcore_action_parts(role_graph, hard_item, composition, axis_values, detail_density, action_family) role_graph = parts.role_graph hard_item = parts.hard_item detail = parts.detail diff --git a/krea_formatter.py b/krea_formatter.py index 23b508f..78e45ed 100644 --- a/krea_formatter.py +++ b/krea_formatter.py @@ -502,7 +502,14 @@ def _normal_row_to_krea(row: dict[str, Any], detail_level: str, style_mode: str) item = _natural_label_text(item, cast_labels) axis_values = _sanitize_hardcore_axis_values(row.get("item_axis_values")) detail_density = _normalize_hardcore_detail_density(row.get("hardcore_detail_density")) - action = _hardcore_action_sentence(role_graph, item, source_composition, axis_values, detail_density) + action = _hardcore_action_sentence( + role_graph, + item, + source_composition, + axis_values, + detail_density, + row.get("action_family"), + ) action = _pov_action_phrase(action, pov_labels, role_graph, item, source_composition, axis_values, detail_density) output_composition = _pov_composition_text(composition, pov_labels) parts = [ @@ -633,6 +640,7 @@ def _insta_pair_to_krea(row: dict[str, Any], detail_level: str, style_mode: str) hard_source_composition, hard_axis_values, hard_detail_density, + hard.get("action_family") or row.get("action_family"), ) hard_action = _pov_action_phrase( hard_action, diff --git a/prompt_builder.py b/prompt_builder.py index 4583819..04c72eb 100644 --- a/prompt_builder.py +++ b/prompt_builder.py @@ -15,6 +15,7 @@ try: sanitize_hardcore_axis_values as _sanitize_hardcore_axis_values, sanitize_hardcore_environment_anchors as _sanitize_hardcore_environment_anchors, ) + from .hardcore_action_metadata import source_hardcore_action_family from .prompt_hygiene import ( sanitize_caption_text, sanitize_negative_text, @@ -27,6 +28,7 @@ except ImportError: # Allows local smoke tests with `python -c`. sanitize_hardcore_axis_values as _sanitize_hardcore_axis_values, sanitize_hardcore_environment_anchors as _sanitize_hardcore_environment_anchors, ) + from hardcore_action_metadata import source_hardcore_action_family from prompt_hygiene import ( sanitize_caption_text, sanitize_negative_text, @@ -490,6 +492,49 @@ HARDCORE_POSITION_AXIS_KEYS = { "aftercare_act", "cleanup_detail", } + +HARDCORE_SOURCE_FAMILY_BY_SUBCATEGORY = { + "penetrative_sex": "penetrative", + "foreplay_teasing": "foreplay", + "body_worship_touching": "interaction", + "clothing_position_transitions": "interaction", + "dominant_guidance": "interaction", + "camera_performance": "interaction", + "manual_stimulation": "manual", + "oral_sex": "oral", + "outercourse_sex": "outercourse", + "anal_double_penetration": "anal", + "threesomes": "threesome", + "group_coordination": "interaction", + "group_sex_orgy": "group", + "cumshot_climax": "climax", + "aftercare_cleanup": "interaction", +} + + +def _hardcore_source_position_family(subcategory: dict[str, Any], config: dict[str, Any] | None = None) -> str: + slug = str(subcategory.get("slug") or subcategory.get("name") or "").strip().lower() + family = HARDCORE_SOURCE_FAMILY_BY_SUBCATEGORY.get(slug, "") + if family: + return family + config_family = _normalize_hardcore_position_family((config or {}).get("family"), "") + return "" if config_family == "any" else config_family + + +def _hardcore_position_keys(*parts: Any, axis_values: dict[str, Any] | None = None) -> list[str]: + text_parts = [str(part or "") for part in parts if str(part or "").strip()] + if isinstance(axis_values, dict): + text_parts.extend(str(value or "") for value in axis_values.values() if str(value or "").strip()) + text = " ".join(text_parts).lower() + if not text: + return [] + keys: list[str] = [] + for key, tokens in HARDCORE_POSITION_KEY_MATCHES.items(): + if any(token in text for token in tokens): + keys.append(key) + return keys + + CAMERA_ORBIT_FRAMING_CHOICES = [ "from_zoom", "wide", @@ -7055,6 +7100,27 @@ def _build_custom_row( if is_pose_category: source_composition = _sanitize_hardcore_environment_anchors(source_composition) composition = _pov_composition_prompt(source_composition, pov_character_labels) + position_family = "" + position_keys: list[str] = [] + position_key = "" + action_family = "" + if is_pose_category: + position_family = _hardcore_source_position_family(subcategory, parsed_hardcore_position_config) + position_keys = _hardcore_position_keys( + item_text, + source_role_graph, + source_composition, + pose, + axis_values=item_axis_values, + ) + position_key = position_keys[0] if position_keys else "" + action_family = source_hardcore_action_family( + position_family, + source_role_graph, + item_text, + source_composition, + item_axis_values, + ) negative_prompt = str(_merged_field(category, subcategory, item, "negative_prompt", g.NEGATIVE_PROMPT)) positive_suffix = str(_merged_field(category, subcategory, item, "positive_suffix", GENERIC_POSITIVE_SUFFIX)) @@ -7096,6 +7162,10 @@ def _build_custom_row( "composition_config": parsed_composition_config if _composition_config_active(parsed_composition_config) else {}, "role_graph": role_graph, "source_role_graph": source_role_graph, + "action_family": action_family, + "position_family": position_family, + "position_key": position_key, + "position_keys": position_keys, "pov_character_labels": pov_character_labels, "pov_prompt_directive": _pov_prompt_directive(pov_character_labels), "cast_descriptors": cast_descriptor_text, @@ -7170,6 +7240,10 @@ def _build_custom_row( "content_seed_axis": content_axis, "role_graph": role_graph, "source_role_graph": source_role_graph, + "action_family": action_family, + "position_family": position_family, + "position_key": position_key, + "position_keys": position_keys, "source_composition": source_composition, "pov_character_labels": pov_character_labels, "pov_prompt_directive": _pov_prompt_directive(pov_character_labels), diff --git a/tools/prompt_smoke.py b/tools/prompt_smoke.py index fc078e2..f0799a3 100644 --- a/tools/prompt_smoke.py +++ b/tools/prompt_smoke.py @@ -364,14 +364,14 @@ def smoke_config_route_location_theme() -> None: def smoke_hardcore_category_routes() -> None: cast = _character_cast() cases = [ - ("hardcore_penetration", "Penetrative sex", "penetration_only"), - ("hardcore_oral", "Oral sex", "oral_only"), - ("hardcore_manual", "Manual stimulation", "manual_only"), - ("hardcore_outercourse", "Outercourse and genital teasing", "outercourse_only"), - ("hardcore_foreplay", "Foreplay and teasing", "foreplay_only"), - ("hardcore_aftercare", "Aftercare and cleanup", "interaction_only"), + ("hardcore_penetration", "Penetrative sex", "penetration_only", "penetrative", {"penetration", "default"}), + ("hardcore_oral", "Oral sex", "oral_only", "oral", {"oral"}), + ("hardcore_manual", "Manual stimulation", "manual_only", "manual", {"foreplay", "outercourse"}), + ("hardcore_outercourse", "Outercourse and genital teasing", "outercourse_only", "outercourse", {"outercourse"}), + ("hardcore_foreplay", "Foreplay and teasing", "foreplay_only", "foreplay", {"foreplay"}), + ("hardcore_aftercare", "Aftercare and cleanup", "interaction_only", "interaction", {"foreplay"}), ] - for index, (name, subcategory, focus) in enumerate(cases, start=1101): + for index, (name, subcategory, focus, position_family, action_families) in enumerate(cases, start=1101): row = _prompt_row( name=name, category="Hardcore sexual poses", @@ -384,6 +384,9 @@ def smoke_hardcore_category_routes() -> None: ) _expect_custom_row(row, name) _expect(row.get("subject_type") == "configured_cast", f"{name} should use configured cast") + _expect(row.get("position_family") == position_family, f"{name} position_family mismatch: {row.get('position_family')}") + _expect(row.get("action_family") in action_families, f"{name} action_family mismatch: {row.get('action_family')}") + _expect(isinstance(row.get("position_keys"), list), f"{name} position_keys missing") _expect_formatter_outputs(row, name, target="single")