From a8d69083cdf07da8fbf2fd0aae02d05b862957b2 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Sat, 27 Jun 2026 14:56:08 +0200 Subject: [PATCH] Inherit hardcore template metadata --- categories/sexual_poses.json | 60 +++++++++++++++++++++++++ category_template_metadata.py | 37 ++++++++++++++++ docs/prompt-pool-routing-map.md | 12 +++-- row_item.py | 13 +++++- row_route_metadata.py | 3 +- tools/prompt_map_audit.py | 48 ++++++++++++++++++++ tools/prompt_smoke.py | 77 +++++++++++++++++++++++++++++++++ 7 files changed, 244 insertions(+), 6 deletions(-) diff --git a/categories/sexual_poses.json b/categories/sexual_poses.json index 71c9f1e..93db4e3 100644 --- a/categories/sexual_poses.json +++ b/categories/sexual_poses.json @@ -92,6 +92,10 @@ "inherit_expressions": false, "inherit_compositions": false, "weight": 0.75, + "item_template_metadata": { + "action_family": "foreplay", + "position_family": "foreplay" + }, "item_label": "Foreplay action", "positive_suffix": "Use clear adult body contact, readable hands and faces, visible undressing, exposed skin, warm erotic lighting, crisp comic linework, detailed hatching, and tactile textured paper.", "prompt_template": "{subject_phrase}, all 21+ consenting adults: {style}. Cast: {cast_summary}. Role graph: {role_graph} Foreplay action: {item}. Setting: {scene}. Composition: {composition}. Facial expressions: {expression}. Make the scene explicit through adult body contact, kissing, caressing, undressing, visible arousal, exposed skin, and readable hand placement. {positive_suffix} Avoid: {negative_prompt}.", @@ -211,6 +215,10 @@ "inherit_expressions": false, "inherit_compositions": false, "weight": 0.85, + "item_template_metadata": { + "action_family": "foreplay", + "position_family": "manual" + }, "item_label": "Manual action", "positive_suffix": "Use clear adult manual contact, readable hands, explicit body positioning, exposed skin, warm erotic lighting, crisp comic linework, detailed hatching, and tactile textured paper.", "prompt_template": "{subject_phrase}, all 21+ consenting adults: {style}. Cast: {cast_summary}. Role graph: {role_graph} Manual action: {item}. Setting: {scene}. Composition: {composition}. Facial expressions: {expression}. Make the scene explicit through adult manual stimulation, visible hands, exposed skin, clear body positioning, and readable reaction. {positive_suffix} Avoid: {negative_prompt}.", @@ -316,6 +324,10 @@ "inherit_expressions": false, "inherit_compositions": false, "weight": 0.7, + "item_template_metadata": { + "action_family": "foreplay", + "position_family": "interaction" + }, "item_label": "Body interaction", "positive_suffix": "Use readable adult body contact, hands and mouth on skin, exposed skin, warm erotic lighting, crisp comic linework, detailed hatching, and tactile textured paper.", "prompt_template": "{subject_phrase}, all 21+ consenting adults: {style}. Cast: {cast_summary}. Role graph: {role_graph} Body interaction: {item}. Setting: {scene}. Composition: {composition}. Facial expressions: {expression}. Make the scene explicit through adult body worship, close skin contact, mouth and hand placement, exposed skin, and readable body positioning. {positive_suffix} Avoid: {negative_prompt}.", @@ -425,6 +437,10 @@ "inherit_expressions": false, "inherit_compositions": false, "weight": 0.65, + "item_template_metadata": { + "action_family": "foreplay", + "position_family": "interaction" + }, "item_label": "Transition action", "positive_suffix": "Use readable adult movement, clothing being moved, hands guiding bodies, exposed skin, warm erotic lighting, crisp comic linework, detailed hatching, and tactile textured paper.", "prompt_template": "{subject_phrase}, all 21+ consenting adults: {style}. Cast: {cast_summary}. Role graph: {role_graph} Transition action: {item}. Setting: {scene}. Composition: {composition}. Facial expressions: {expression}. Make the scene explicit through adult undressing, position changes, visible hands, exposed skin, and clear movement from one sexual beat to the next. {positive_suffix} Avoid: {negative_prompt}.", @@ -530,6 +546,10 @@ "inherit_expressions": false, "inherit_compositions": false, "weight": 0.55, + "item_template_metadata": { + "action_family": "foreplay", + "position_family": "interaction" + }, "item_label": "Guidance action", "positive_suffix": "Use consensual adult control, readable hand placement, clear body positioning, exposed skin, warm erotic lighting, crisp comic linework, detailed hatching, and tactile textured paper.", "prompt_template": "{subject_phrase}, all 21+ consenting adults: {style}. Cast: {cast_summary}. Role graph: {role_graph} Guidance action: {item}. Setting: {scene}. Composition: {composition}. Facial expressions: {expression}. Make the scene explicit through consensual adult guidance, hair or wrist control, body positioning, visible hands, exposed skin, and clear power dynamic. {positive_suffix} Avoid: {negative_prompt}.", @@ -640,6 +660,10 @@ "inherit_expressions": false, "inherit_compositions": false, "weight": 0.6, + "item_template_metadata": { + "action_family": "foreplay", + "position_family": "interaction" + }, "item_label": "Camera performance", "positive_suffix": "Use creator-shot adult presentation, readable camera-facing pose, exposed skin, clear hand placement, warm erotic lighting, crisp comic linework, detailed hatching, and tactile textured paper.", "prompt_template": "{subject_phrase}, all 21+ consenting adults: {style}. Cast: {cast_summary}. Role graph: {role_graph} Camera performance: {item}. Setting: {scene}. Composition: {composition}. Facial expressions: {expression}. Make the scene explicit through camera-aware adult presentation, body opened or displayed to the viewer, visible hands, exposed skin, and clean creator-shot framing. {positive_suffix} Avoid: {negative_prompt}.", @@ -744,6 +768,10 @@ "inherit_expressions": false, "inherit_compositions": false, "weight": 0.55, + "item_template_metadata": { + "action_family": "foreplay", + "position_family": "interaction" + }, "item_label": "Group interaction", "positive_suffix": "Use readable adult group coordination, clear body spacing, visible watching/touching roles, exposed skin, warm erotic lighting, crisp comic linework, detailed hatching, and tactile textured paper.", "prompt_template": "{subject_phrase}, all 21+ consenting adults: {style}. Cast: {cast_summary}. Role graph: {role_graph} Group interaction: {item}. Setting: {scene}. Composition: {composition}. Facial expressions: {expression}. Make the scene explicit through adult group coordination, watching, guiding hands, body presentation, exposed skin, and clear role spacing. {positive_suffix} Avoid: {negative_prompt}.", @@ -846,6 +874,10 @@ "inherit_expressions": false, "inherit_compositions": false, "weight": 0.35, + "item_template_metadata": { + "action_family": "foreplay", + "position_family": "interaction" + }, "item_label": "Aftermath interaction", "positive_suffix": "Use adult post-sex intimacy, readable bodies and hands, visible aftermath details, warm erotic lighting, crisp comic linework, detailed hatching, and tactile textured paper.", "prompt_template": "{subject_phrase}, all 21+ consenting adults: {style}. Cast: {cast_summary}. Role graph: {role_graph} Aftermath interaction: {item}. Setting: {scene}. Composition: {composition}. Facial expressions: {expression}. Make the scene explicit through adult post-sex closeness, cleanup, visible skin, relaxed body contact, aftermath details, and readable hands and faces. {positive_suffix} Avoid: {negative_prompt}.", @@ -950,6 +982,10 @@ "inherit_expressions": false, "inherit_compositions": false, "weight": 1.0, + "item_template_metadata": { + "action_family": "penetration", + "position_family": "penetrative" + }, "scene_pools": ["hardcore_penetrative_scenes", "hardcore_bed_scenes", "hardcore_mirror_scenes"], "expression_pools": ["hardcore_penetration_expressions"], "composition_pools": ["penetration_compositions"], @@ -1123,6 +1159,10 @@ "inherit_expressions": false, "inherit_compositions": false, "weight": 1.0, + "item_template_metadata": { + "action_family": "oral", + "position_family": "oral" + }, "scene_pools": ["hardcore_oral_scenes", "hardcore_bed_scenes", "hardcore_mirror_scenes"], "expression_pools": ["hardcore_oral_expressions"], "composition_pools": ["oral_compositions"], @@ -1266,6 +1306,10 @@ "inherit_expressions": false, "inherit_compositions": false, "weight": 1.0, + "item_template_metadata": { + "action_family": "outercourse", + "position_family": "outercourse" + }, "scene_pools": ["hardcore_private_scenes", "hardcore_bed_scenes", "hardcore_mirror_scenes"], "expression_pools": ["hardcore_outercourse_expressions"], "compositions": [ @@ -1420,6 +1464,10 @@ "inherit_expressions": false, "inherit_compositions": false, "weight": 1.0, + "item_template_metadata": { + "action_family": "default", + "position_family": "anal" + }, "scene_pools": ["hardcore_anal_scenes", "hardcore_bed_scenes", "hardcore_mirror_scenes"], "expression_pools": ["hardcore_anal_dp_expressions"], "composition_pools": ["anal_dp_compositions"], @@ -1639,6 +1687,10 @@ "inherit_expressions": false, "inherit_compositions": false, "weight": 1.0, + "item_template_metadata": { + "action_family": "default", + "position_family": "threesome" + }, "scene_pools": ["hardcore_threesome_scenes", "hardcore_group_scenes", "hardcore_mirror_scenes"], "expression_pools": ["hardcore_group_expressions"], "composition_pools": ["threesome_compositions"], @@ -1822,6 +1874,10 @@ "inherit_expressions": false, "inherit_compositions": false, "weight": 1.0, + "item_template_metadata": { + "action_family": "default", + "position_family": "group" + }, "scene_pools": ["hardcore_group_scenes"], "expression_pools": ["hardcore_group_expressions"], "composition_pools": ["group_sex_compositions"], @@ -1994,6 +2050,10 @@ "inherit_expressions": false, "inherit_compositions": false, "weight": 1.0, + "item_template_metadata": { + "action_family": "climax", + "position_family": "climax" + }, "scene_pools": ["hardcore_climax_scenes", "hardcore_bed_scenes", "hardcore_mirror_scenes"], "expression_pools": ["hardcore_climax_expressions"], "composition_pools": ["climax_compositions"], diff --git a/category_template_metadata.py b/category_template_metadata.py index 0a16b61..4f62b78 100644 --- a/category_template_metadata.py +++ b/category_template_metadata.py @@ -34,6 +34,43 @@ def template_metadata(item: Any) -> dict[str, Any]: return {key: item[key] for key in TEMPLATE_METADATA_KEYS if key in item} +def merge_template_metadata(*metadata_values: Any) -> dict[str, Any]: + merged: dict[str, Any] = {} + for value in metadata_values: + metadata = template_metadata(value) + if not metadata: + continue + for key in ("action_family", "action_type", "family", "position_family", "position_key"): + if str(metadata.get(key) or "").strip(): + merged[key] = metadata[key] + if metadata.get("position_keys") is not None: + merged["position_keys"] = merge_position_keys( + template_position_keys(merged), + template_position_keys(metadata), + ) + hint_map = formatter_hints(metadata) + if hint_map: + existing = formatter_hints(merged) + for route, hints in hint_map.items(): + for hint in hints: + if hint not in existing.setdefault(route, []): + existing[route].append(hint) + merged["formatter_hint"] = existing + return merged + + +def inherited_template_metadata(*containers: Any) -> dict[str, Any]: + metadata_parts: list[dict[str, Any]] = [] + for container in containers: + if not isinstance(container, dict): + continue + nested = container.get("item_template_metadata") + if isinstance(nested, dict): + metadata_parts.append(nested) + metadata_parts.append(container) + return merge_template_metadata(*metadata_parts) + + def template_position_family(metadata: dict[str, Any]) -> str: return normalize_hardcore_position_family( metadata.get("position_family") or metadata.get("family"), diff --git a/docs/prompt-pool-routing-map.md b/docs/prompt-pool-routing-map.md index 3d10124..ab1b346 100644 --- a/docs/prompt-pool-routing-map.md +++ b/docs/prompt-pool-routing-map.md @@ -75,7 +75,7 @@ Core helper ownership: | `builder_prompt_route.py` | Single-prompt builder orchestration, input normalization, seed-axis setup, built-in/custom row routing, legacy location/composition handling, camera application, and final prompt-row normalization. | | `builder_config_route.py` | Config-driven prompt-builder request parsing, category/cast/profile/filter helper-node mapping, and direct `build_prompt` kwarg assembly. | | `category_extensions.py` | JSON `pool_extensions`, legacy pool patching, built-in category choice lists, and category/subcategory UI choices. | -| `category_template_metadata.py` | Object-style item-template metadata extraction, action/position family normalization, position-key normalization, key merging, and audit validation errors. | +| `category_template_metadata.py` | Object-style and inherited item-template metadata extraction, action/position family normalization, position-key normalization, key merging, formatter-hint merging, and audit validation errors. | | `row_item.py` | Row item selection, weighted item/pair choice, item-template axis filling, and oral/outercourse axis compatibility filters. | | `row_category_route.py` | Row category/subcategory/item route resolution behind `CategoryItemRoute`, hardcore position-category filtering, cast-count adjustment, pose-vs-content seed-axis choice, item metadata collection, legacy dict compatibility, and pose-category item sanitizing. | | `row_rendering.py` | Row prompt/caption text-field resolution, template selection, safe formatting, default prompt templates, configured-cast descriptor insertion, and POV directive insertion. | @@ -266,6 +266,12 @@ Important JSON keys: map keyed by `krea`, `sdxl`, or `caption`; aliases such as `krea2` and `training_caption` are normalized by `category_template_metadata.py` and consumed only by the matching formatter route plus the shared `all` route. +- `item_template_metadata`: optional default route metadata on a category, + subcategory, or item. String templates inherit it, and object templates can + override it while formatter hints merge. +- For mixed hardcore subcategories, `action_family: default` keeps the explicit + position family while allowing `row_route_metadata.py` to infer the semantic + action family from the selected action/role text. - `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. @@ -505,8 +511,8 @@ plain prompt text. When debugging, inspect these fields before editing pools. | `content_seed_axis` | `row_category_route.select_category_item_route` | Debug | Shows whether the item/action was driven by `content` or `pose`. Critical for hardcore pose categories. | | `item` | `row_category_route.select_category_item_route` or Insta override | Krea/SDXL/Naturalizer | Clothing item, category item, or sexual scene/action text. | | `item_axis_values` | `row_category_route.select_category_item_route` | Krea hardcore rewrite, SDXL tags | Filled template axes such as position/action/detail values. | -| `item_template_metadata` | `row_category_route.select_category_item_route` | 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. | -| `formatter_hints` | `row_category_route.select_category_item_route` | Krea/SDXL/Naturalizer route specialization, debug | Normalized route-specific hints from object-style item templates, keyed by `all`, `krea`, `sdxl`, or `caption`; each formatter consumes `all` plus its own route only. | +| `item_template_metadata` | `row_category_route.select_category_item_route` | Debug, Krea/SDXL/Naturalizer route metadata | Metadata inherited from category/subcategory/item `item_template_metadata` plus selected object-template metadata; used to prefer explicit action/position families and keys before inference. | +| `formatter_hints` | `row_category_route.select_category_item_route` | Krea/SDXL/Naturalizer route specialization, debug | Normalized route-specific hints inherited from template metadata, keyed by `all`, `krea`, `sdxl`, or `caption`; each formatter consumes `all` plus its own route only. | | `action_family` | `row_route_metadata.resolve_action_position_route` | 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` | `row_route_metadata.resolve_action_position_route` | Debug/filtering | Source/UI hardcore family selected by template metadata or subcategory, such as `manual`, `interaction`, `oral`, `anal`, or `climax`. | | `position_key`, `position_keys` | `row_route_metadata.resolve_action_position_route` | Debug/future filters | Concrete position tokens from object-template metadata and inferred axes/role text, such as `kneeling`, `doggy`, `boobjob`, or `open_thighs`. | diff --git a/row_item.py b/row_item.py index e7d2b5d..f303212 100644 --- a/row_item.py +++ b/row_item.py @@ -309,6 +309,7 @@ def compose_item( ) -> tuple[str, str, dict[str, str], dict[str, Any]]: templates = category_policy.template_list(category, subcategory, item, "item_templates") axes = category_policy.merged_axes(category, subcategory, item) + inherited_metadata = template_policy.inherited_template_metadata(category, subcategory, item) if templates and axes: template_entry = weighted_choice(rng, category_policy.compatible_entries(templates, women_count, men_count)) template = entry_text(template_entry) @@ -339,5 +340,13 @@ def compose_item( axis_values[name] = entry_text(weighted_choice(rng, values)) item_prompt = _format(template, axis_values).strip() name = item_name(item) or subcategory["name"] - return item_prompt, name, axis_values, template_policy.template_metadata(template_entry) - return item_text(item), item_name(item), {}, template_policy.template_metadata(item) + return ( + item_prompt, + name, + axis_values, + template_policy.merge_template_metadata(inherited_metadata, template_policy.template_metadata(template_entry)), + ) + return item_text(item), item_name(item), {}, template_policy.merge_template_metadata( + inherited_metadata, + template_policy.template_metadata(item), + ) diff --git a/row_route_metadata.py b/row_route_metadata.py index 9e5c475..4e1e53e 100644 --- a/row_route_metadata.py +++ b/row_route_metadata.py @@ -83,7 +83,8 @@ def resolve_action_position_route_result( template_policy.template_position_keys(metadata), inferred_position_keys, ) - action_family = template_policy.template_action_family(metadata) + explicit_action_family = template_policy.template_action_family(metadata) + action_family = "" if explicit_action_family == "default" else explicit_action_family if not action_family: action_family = source_hardcore_action_family( position_family, diff --git a/tools/prompt_map_audit.py b/tools/prompt_map_audit.py index 5698273..9400856 100644 --- a/tools/prompt_map_audit.py +++ b/tools/prompt_map_audit.py @@ -250,6 +250,19 @@ def _template_axis_errors(path: str, node: dict[str, Any]) -> list[tuple[str, st return errors +def _container_template_metadata_errors(path: str, node: dict[str, Any]) -> list[tuple[str, str]]: + if "item_template_metadata" not in node: + return [] + metadata = node.get("item_template_metadata") + if not isinstance(metadata, dict): + return [(f"{path}.item_template_metadata", "item_template_metadata must be an object")] + normalized = template_metadata_policy.template_metadata(metadata) + return [ + (f"{path}.item_template_metadata", issue) + for issue in template_metadata_policy.template_metadata_errors(normalized) + ] + + def _walk_json_references( value: Any, *, @@ -261,6 +274,10 @@ def _walk_json_references( ) -> None: if isinstance(value, dict): errors.extend((file_name, item_path, issue) for item_path, issue in _template_axis_errors(path, value)) + errors.extend( + (file_name, item_path, issue) + for item_path, issue in _container_template_metadata_errors(path, value) + ) for key, child in value.items(): if at_root and key in POOL_DEFINITION_KEYS and isinstance(child, dict): for pool_name, pool_values in child.items(): @@ -318,6 +335,30 @@ def _json_reference_errors(paths: list[Path]) -> list[tuple[str, str, str]]: return errors +def _hardcore_template_metadata_errors(paths: list[Path]) -> list[tuple[str, str, str]]: + errors: list[tuple[str, str, str]] = [] + for path in paths: + data = _load_category_json(path) + for category in data.get("categories") or []: + if str(category.get("slug") or "") != "hardcore_sexual_poses": + continue + for subcategory in category.get("subcategories") or []: + templates = subcategory.get("item_templates") + if not isinstance(templates, list) or not templates: + continue + sub_path = f"categories.{category.get('slug')}.subcategories.{subcategory.get('slug')}" + metadata = subcategory.get("item_template_metadata") + if not isinstance(metadata, dict): + errors.append((path.name, sub_path, "missing item_template_metadata default block")) + continue + normalized = template_metadata_policy.template_metadata(metadata) + if not template_metadata_policy.template_action_family(normalized): + errors.append((path.name, f"{sub_path}.item_template_metadata", "missing normalized action_family")) + if not template_metadata_policy.template_position_family(normalized): + errors.append((path.name, f"{sub_path}.item_template_metadata", "missing normalized position_family")) + return errors + + def _smoke_case_names(path: Path) -> set[str]: if not path.exists(): return set() @@ -445,6 +486,13 @@ def main() -> int: return 1 print("OK: all JSON pool references and item template axes resolve.") + print("\n# Hardcore Template Metadata Validation") + hardcore_metadata_errors = _hardcore_template_metadata_errors(category_paths) + if hardcore_metadata_errors: + print_table(("File", "Path", "Issue"), hardcore_metadata_errors) + return 1 + print("OK: hardcore template subcategories define explicit route metadata defaults.") + print("\n# Routing Documentation Validation") routing_doc_errors = _routing_doc_errors() if routing_doc_errors: diff --git a/tools/prompt_smoke.py b/tools/prompt_smoke.py index ef1f12a..187ad71 100644 --- a/tools/prompt_smoke.py +++ b/tools/prompt_smoke.py @@ -3936,6 +3936,19 @@ def smoke_hardcore_position_config_policy() -> None: "generic contact", ) _expect(source_action_family == "outercourse", "Source action-family fallback should accept hyphenated source aliases") + default_action_route = row_route_metadata.resolve_action_position_route( + is_pose_category=True, + subcategory={"slug": "anal_double_penetration"}, + hardcore_position_config=None, + item_template_metadata={"action_family": "default", "position_family": "anal"}, + item_text="toy-assisted double penetration with front-and-back contact", + source_role_graph="one partner between two bodies", + source_composition="", + pose="", + item_axis_values={"double_act": "toy-assisted double penetration"}, + ) + _expect(default_action_route.get("position_family") == "anal", "Default-action metadata should preserve position family") + _expect(default_action_route.get("action_family") == "toy_double", "Default-action metadata should still allow action inference") item_text, item_name, axis_values, template_metadata = pb._compose_item( random.Random(42), {}, @@ -3974,6 +3987,70 @@ def smoke_hardcore_position_config_policy() -> None: _expect(formatter_hints.get("krea") == ["keep mouth contact readable"], "Template metadata route lost Krea formatter hint") _expect(formatter_hints.get("sdxl") == ["oral contact", "kneeling oral"], "Template metadata route lost SDXL formatter hints") _expect(formatter_hints.get("caption") == ["oral contact caption detail"], "Template metadata route lost caption formatter hint") + inherited_text, _inherited_name, inherited_axis_values, inherited_metadata = pb._compose_item( + random.Random(42), + {}, + { + "name": "Inherited metadata route", + "item_template_metadata": { + "action_family": "foreplay", + "position_family": "manual", + "position_keys": ["kneeling"], + "formatter_hint": {"caption": "inherited caption cue"}, + }, + "item_templates": ["{act} in {position}"], + "item_axes": { + "act": ["hand stimulation"], + "position": ["kneeling manual position"], + }, + }, + "Inherited metadata route", + women_count=1, + men_count=1, + ) + _expect(inherited_text == "hand stimulation in kneeling manual position", "Inherited template metadata changed item text") + _expect(inherited_axis_values == {"act": "hand stimulation", "position": "kneeling manual position"}, "Inherited template metadata lost axis values") + _expect(inherited_metadata.get("action_family") == "foreplay", "String template did not inherit action family") + _expect(inherited_metadata.get("position_family") == "manual", "String template did not inherit position family") + _expect(pb._template_position_keys(inherited_metadata) == ["kneeling"], "String template did not inherit position keys") + _expect( + route_metadata.row_formatter_hints({"item_template_metadata": inherited_metadata}, "caption") == ["inherited caption cue"], + "String template did not inherit formatter hints", + ) + override_text, _override_name, _override_axis_values, override_metadata = pb._compose_item( + random.Random(42), + {}, + { + "name": "Override metadata route", + "item_template_metadata": { + "action_family": "foreplay", + "position_family": "manual", + "formatter_hint": {"all": "inherited shared cue"}, + }, + "item_templates": [ + { + "template": "{act} in {position}", + "action_family": "oral", + "formatter_hint": {"krea2": "override krea cue"}, + } + ], + "item_axes": { + "act": ["mouth contact"], + "position": ["kneeling oral position"], + }, + }, + "Override metadata route", + women_count=1, + men_count=1, + ) + _expect(override_text == "mouth contact in kneeling oral position", "Override template metadata changed item text") + _expect(override_metadata.get("action_family") == "oral", "Template object did not override inherited action family") + _expect(override_metadata.get("position_family") == "manual", "Template object should keep inherited position family when absent") + _expect( + route_metadata.row_formatter_hints({"item_template_metadata": override_metadata}, "krea") + == ["inherited shared cue", "override krea cue"], + "Template metadata did not merge inherited and template formatter hints", + ) route_row = { "action_family": "penetrative", "position_family": "Oral",