Add hardcore action family metadata

This commit is contained in:
2026-06-26 16:38:25 +02:00
parent 0e49aed8ac
commit 8668dfec9d
8 changed files with 237 additions and 60 deletions
+9 -4
View File
@@ -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
+8 -2
View File
@@ -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
+101
View File
@@ -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)
+24 -45
View File
@@ -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)
+2 -1
View File
@@ -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
+9 -1
View File
@@ -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,
+74
View File
@@ -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),
+10 -7
View File
@@ -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")