diff --git a/categories/sexual_poses.json b/categories/sexual_poses.json index 6138acb..71c9f1e 100644 --- a/categories/sexual_poses.json +++ b/categories/sexual_poses.json @@ -954,7 +954,11 @@ "expression_pools": ["hardcore_penetration_expressions"], "composition_pools": ["penetration_compositions"], "item_templates": [ - "{penetration_act} in {position}, with {body_contact}, {intensity}, and {visibility}", + { + "template": "{penetration_act} in {position}, with {body_contact}, {intensity}, and {visibility}", + "action_family": "penetration", + "position_family": "penetrative" + }, "{position} while {penetration_act}, {hand_detail}, {mouth_detail}, and {visibility}", "{penetration_act} from {angle}, with {leg_detail}, {body_contact}, and {intensity}", "hardcore {position} featuring {penetration_act}, {thrust_detail}, {hand_detail}, and {visibility}", @@ -1123,7 +1127,11 @@ "expression_pools": ["hardcore_oral_expressions"], "composition_pools": ["oral_compositions"], "item_templates": [ - "{oral_act} in {position}, with {hand_detail}, {expression_detail}, and {visibility}", + { + "template": "{oral_act} in {position}, with {hand_detail}, {expression_detail}, and {visibility}", + "action_family": "oral", + "position_family": "oral" + }, "{position} featuring {oral_act}, {body_contact}, {saliva_detail}, and {climax_hint}", "{oral_act} from {angle}, with {mouth_detail}, {hand_detail}, and {visibility}", "hardcore oral scene with {oral_act}, {body_contact}, {saliva_detail}, and {expression_detail}", @@ -1271,7 +1279,11 @@ {"text": "close candid creator-shot frame centered on non-penetrative genital contact", "min_people": 2, "max_people": 3} ], "item_templates": [ - "{outer_act} in {position}, with {contact_detail}, {hand_detail}, and {visibility}", + { + "template": "{outer_act} in {position}, with {contact_detail}, {hand_detail}, and {visibility}", + "action_family": "outercourse", + "position_family": "outercourse" + }, "{position} featuring {outer_act}, {body_contact}, {texture_detail}, seen from a {angle} view", "{angle} view of {outer_act}, with {visibility}, {contact_detail}, and {expression_detail}", "explicit non-penetrative sex pose: {outer_act}, {position}, {contact_detail}, and {visibility}", @@ -1986,7 +1998,11 @@ "expression_pools": ["hardcore_climax_expressions"], "composition_pools": ["climax_compositions"], "item_templates": [ - "{climax_act} with {fluid_location}, {body_position}, {expression_detail}, and {visibility}", + { + "template": "{climax_act} with {fluid_location}, {body_position}, {expression_detail}, and {visibility}", + "action_family": "climax", + "position_family": "climax" + }, "{body_position} during {climax_act}, with {hand_detail}, {fluid_location}, and {fluid_detail}", "{angle} aftermath view with {body_position}, {body_contact}, and {visibility}", "hardcore post-ejaculation scene with {fluid_location}, {body_position}, {expression_detail}, and {visibility}", diff --git a/docs/prompt-architecture-improvement-plan.md b/docs/prompt-architecture-improvement-plan.md index 937ac2f..524a2e0 100644 --- a/docs/prompt-architecture-improvement-plan.md +++ b/docs/prompt-architecture-improvement-plan.md @@ -332,14 +332,17 @@ Keep here: - named scene/expression/composition pools; - item templates and axes; - direct category-specific wording. +- optional object-style item templates with route metadata such as + `action_family`, `action_type`, `position_family`, `family`, `position_key`, + and `position_keys`; string templates remain valid and fall back to Python + inference. Improve later: -- introduce optional `family` and `action_type` fields on item templates so - Python filters do less keyword guessing; - add `formatter_hint` fields only where needed, not globally; - keep `tools/prompt_map_audit.py` passing; it now checks referenced - expression/composition/scene pools and item-template axes. + expression/composition/scene pools and item-template axes for both string and + object templates. ### Node / UI Path diff --git a/docs/prompt-pool-routing-map.md b/docs/prompt-pool-routing-map.md index 84a3517..378886d 100644 --- a/docs/prompt-pool-routing-map.md +++ b/docs/prompt-pool-routing-map.md @@ -216,6 +216,9 @@ Important JSON keys: - `subcategories`: selectable subcategories inside a category. - `items`: item/action entries selected by the content or pose axis. - `item_templates`: templates with axis placeholders. +- `item_templates` entries may be strings or objects with `template` plus + optional route metadata such as `action_family`, `action_type`, + `position_family`, `family`, `position_key`, and `position_keys`. - `axes`: values used to fill `item_templates`. - `scene_pool` / `scene_pools` or direct `scenes`: location road. - `expression_pool` / `expression_pools` or direct `expressions`: expression road. @@ -444,9 +447,10 @@ 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, SDXL tags, natural captions, 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`. | +| `item_template_metadata` | `_compose_item` | Debug, Krea/SDXL/Naturalizer route metadata | Optional metadata from object-style item templates; currently used to prefer explicit action/position families and keys before inference. | +| `action_family` | `item_template_metadata` or `hardcore_action_metadata.source_hardcore_action_family` | Krea hardcore rewrite, SDXL tags, natural captions, debug | Source-aware formatter semantic family such as `foreplay`, `outercourse`, `oral`, `penetration`, `toy_double`, or `climax`. | +| `position_family` | `item_template_metadata` or `_hardcore_source_position_family` | Debug/filtering | Source/UI hardcore family selected by template metadata or subcategory, such as `manual`, `interaction`, `oral`, `anal`, or `climax`. | +| `position_key`, `position_keys` | `item_template_metadata` plus `_hardcore_position_keys` | Debug/future filters | Concrete position tokens from object-template metadata and inferred axes/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. | diff --git a/prompt_builder.py b/prompt_builder.py index feb216e..6f223f3 100644 --- a/prompt_builder.py +++ b/prompt_builder.py @@ -45,7 +45,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 .hardcore_action_metadata import normalize_hardcore_action_family, source_hardcore_action_family from .hardcore_role_graphs import build_hardcore_role_graph except ImportError: # Allows local smoke tests with `python -c`. from category_library import ( @@ -85,7 +85,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 hardcore_action_metadata import normalize_hardcore_action_family, source_hardcore_action_family from hardcore_role_graphs import build_hardcore_role_graph @@ -300,6 +300,53 @@ def _item_name(item: Any) -> str: return _item_text(item) +def _template_metadata(item: Any) -> dict[str, Any]: + if not isinstance(item, dict): + return {} + metadata: dict[str, Any] = {} + for key in ( + "action_family", + "action_type", + "family", + "position_family", + "position_key", + "position_keys", + "formatter_hint", + ): + if key in item: + metadata[key] = item[key] + return metadata + + +def _template_position_family(metadata: dict[str, Any]) -> str: + return _normalize_hardcore_position_family( + metadata.get("position_family") or metadata.get("family"), + "", + ) + + +def _template_position_keys(metadata: dict[str, Any]) -> list[str]: + keys: list[Any] = [] + if metadata.get("position_keys") is not None: + raw_keys = metadata.get("position_keys") + keys.extend(raw_keys if isinstance(raw_keys, list) else [raw_keys]) + if metadata.get("position_key") is not None: + keys.append(metadata.get("position_key")) + return _normalize_hardcore_position_values(keys) + + +def _template_action_family(metadata: dict[str, Any]) -> str: + return normalize_hardcore_action_family(metadata.get("action_family") or metadata.get("action_type"), "") + + +def _merge_position_keys(primary: list[str], fallback: list[str]) -> list[str]: + merged: list[str] = [] + for key in [*primary, *fallback]: + if key and key not in merged: + merged.append(key) + return merged + + def _oral_acts_for_position(values: list[Any], position: str) -> list[Any]: position_text = str(position or "").lower() if not position_text: @@ -503,11 +550,12 @@ def _compose_item( item: Any, women_count: int = 1, men_count: int = 1, -) -> tuple[str, str, dict[str, str]]: +) -> tuple[str, str, dict[str, str], dict[str, Any]]: templates = _template_list(category, subcategory, item, "item_templates") axes = _merged_axes(category, subcategory, item) if templates and axes: - template = _entry_text(_weighted_choice(rng, _compatible_entries(templates, women_count, men_count))) + template_entry = _weighted_choice(rng, _compatible_entries(templates, women_count, men_count)) + template = _entry_text(template_entry) fields = [key for _, key, _, _ in Formatter().parse(template) if key] unique_fields = list(dict.fromkeys(fields)) axis_values: dict[str, str] = {} @@ -535,8 +583,8 @@ def _compose_item( axis_values[name] = _entry_text(_weighted_choice(rng, values)) item_text = _format(template, axis_values).strip() item_name = _item_name(item) or subcategory["name"] - return item_text, item_name, axis_values - return _item_text(item), _item_name(item), {} + return item_text, item_name, axis_values, _template_metadata(template_entry) + return _item_text(item), _item_name(item), {}, _template_metadata(item) def _choose_text(rng: random.Random, items: list[Any]) -> str: @@ -3722,7 +3770,14 @@ def _build_custom_row( content_rng = _axis_rng(seed_config, content_axis, seed, row_number) items = _list_from(subcategory.get("items", [subcategory["name"]])) item = _weighted_choice(content_rng, items) - item_text, item_name, item_axis_values = _compose_item(content_rng, category, subcategory, item, women_count, men_count) + item_text, item_name, item_axis_values, item_template_metadata = _compose_item( + content_rng, + category, + subcategory, + item, + women_count, + men_count, + ) is_pose_category = _is_pose_content_category(category, subcategory) if is_pose_category: item_text = _sanitize_hardcore_environment_anchors(item_text) @@ -3868,22 +3923,29 @@ def _build_custom_row( position_key = "" action_family = "" if is_pose_category: - position_family = _hardcore_source_position_family(subcategory, parsed_hardcore_position_config) - position_keys = _hardcore_position_keys( + template_position_family = _template_position_family(item_template_metadata) + position_family = template_position_family or _hardcore_source_position_family( + subcategory, + parsed_hardcore_position_config, + ) + inferred_position_keys = _hardcore_position_keys( item_text, source_role_graph, source_composition, pose, axis_values=item_axis_values, ) + position_keys = _merge_position_keys(_template_position_keys(item_template_metadata), inferred_position_keys) 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, - ) + action_family = _template_action_family(item_template_metadata) + if not action_family: + 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)) @@ -3991,6 +4053,7 @@ def _build_custom_row( "positive_suffix": positive_suffix, "custom_item": item_name, "item_axis_values": item_axis_values, + "item_template_metadata": item_template_metadata, "scene_text": scene, "location_config": parsed_location_config if _location_config_active(parsed_location_config) else {}, "pose": pose, diff --git a/tools/prompt_map_audit.py b/tools/prompt_map_audit.py index ea00b8d..85f9fe5 100644 --- a/tools/prompt_map_audit.py +++ b/tools/prompt_map_audit.py @@ -177,15 +177,29 @@ def _template_axis_errors(path: str, node: dict[str, Any]) -> list[tuple[str, st axis_names = set(axes) if isinstance(axes, dict) else set() errors: list[tuple[str, str]] = [] for index, template in enumerate(templates): - if not isinstance(template, str): - errors.append((f"{path}.item_templates[{index}]", "template is not a string")) + template_path = f"{path}.item_templates[{index}]" + if isinstance(template, dict): + template_text = str( + template.get("template") + or template.get("prompt") + or template.get("text") + or template.get("description") + or template.get("name") + or "" + ).strip() + elif isinstance(template, str): + template_text = template + else: + template_text = "" + if not template_text: + errors.append((template_path, "template must be a string or object with template/text")) continue - tokens = set(TEMPLATE_TOKEN_RE.findall(template)) + tokens = set(TEMPLATE_TOKEN_RE.findall(template_text)) missing = sorted(token for token in tokens if token not in axis_names) if missing: errors.append( ( - f"{path}.item_templates[{index}]", + template_path, "missing item_axes for placeholders: " + ", ".join(missing), ) ) diff --git a/tools/prompt_smoke.py b/tools/prompt_smoke.py index cb4bcc2..2cc44eb 100644 --- a/tools/prompt_smoke.py +++ b/tools/prompt_smoke.py @@ -1183,6 +1183,35 @@ def smoke_hardcore_position_config_policy() -> None: _expect(keys == ["doggy"], "Hardcore position key detection changed") source_family = hardcore_position_config.hardcore_source_position_family({"slug": "manual_stimulation"}, filtered) _expect(source_family == "manual", "Hardcore source family lookup changed") + item_text, item_name, axis_values, template_metadata = pb._compose_item( + random.Random(42), + {}, + { + "name": "Template metadata route", + "item_templates": [ + { + "template": "{act} in {position}", + "action_family": "oral", + "position_family": "oral", + "position_keys": ["kneeling", "open_thighs"], + } + ], + "item_axes": { + "act": ["mouth contact"], + "position": ["kneeling oral position"], + }, + }, + "Template metadata route", + women_count=1, + men_count=1, + ) + _expect(item_text == "mouth contact in kneeling oral position", "Template metadata route changed composed item text") + _expect(item_name == "Template metadata route", "Template metadata route changed item name") + _expect(axis_values == {"act": "mouth contact", "position": "kneeling oral position"}, "Template metadata route lost axis values") + _expect(template_metadata.get("action_family") == "oral", "Template metadata route lost action family") + _expect(pb._template_position_family(template_metadata) == "oral", "Template metadata route lost position family") + _expect(pb._template_position_keys(template_metadata) == ["kneeling", "open_thighs"], "Template metadata route lost position keys") + _expect(pb._template_action_family(template_metadata) == "oral", "Template metadata route lost normalized action family") def smoke_category_library_route() -> None: @@ -1266,6 +1295,26 @@ def smoke_hardcore_category_routes() -> None: _expect(sdxl_tag in (sdxl.get("sdxl_prompt") or "").lower(), f"{name} SDXL prompt did not include family tag {sdxl_tag!r}") caption, _method = caption_naturalizer.naturalize_caption("", metadata_json=_json(row), trigger=Trigger, include_trigger=True) _expect(caption_label in caption.lower(), f"{name} caption did not include family label {caption_label!r}") + annotated_row = None + for seed in range(1801, 1841): + row = _prompt_row( + name="hardcore_annotated_template", + category="Hardcore sexual poses", + subcategory="Oral sex", + seed=seed, + character_cast=cast, + women_count=1, + men_count=1, + hardcore_position_config=_action_filter("oral_only"), + ) + if row.get("item_template_metadata"): + annotated_row = row + break + _expect(annotated_row is not None, "No annotated item template reached generated row in deterministic seed window") + if annotated_row is not None: + _expect(annotated_row.get("action_family") == "oral", "Annotated item template action_family did not reach row") + _expect(annotated_row.get("position_family") == "oral", "Annotated item template position_family did not reach row") + _expect(annotated_row.get("item_template_metadata", {}).get("action_family") == "oral", "Annotated item metadata missing in row") def smoke_krea_close_foreplay_route() -> None: