From 55fec890a5811f66c548e5ae763d8d78557575f2 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Sat, 27 Jun 2026 09:27:39 +0200 Subject: [PATCH] Extract row route metadata policy --- docs/prompt-architecture-improvement-plan.md | 3 + docs/prompt-pool-routing-map.md | 7 +- prompt_builder.py | 72 ++++++++++-------- row_route_metadata.py | 80 ++++++++++++++++++++ tools/prompt_smoke.py | 65 ++++++++++++++++ 5 files changed, 194 insertions(+), 33 deletions(-) create mode 100644 row_route_metadata.py diff --git a/docs/prompt-architecture-improvement-plan.md b/docs/prompt-architecture-improvement-plan.md index 7361151..9f678dd 100644 --- a/docs/prompt-architecture-improvement-plan.md +++ b/docs/prompt-architecture-improvement-plan.md @@ -131,6 +131,9 @@ Already isolated: - row item selection, weighted item/pair choice, item-template axis filling, and oral/outercourse axis compatibility filters live in `row_item.py`; `prompt_builder.py` keeps public delegate wrappers. +- row action/position route metadata resolution, template metadata precedence, + inferred position-key merging, and source action-family fallback live in + `row_route_metadata.py`; `prompt_builder.py` keeps a public delegate wrapper. - built-in legacy row generation, auto-weighted/auto-full selection, row mode randomization, ratio clamps, and expression-intensity randomization live in `row_generation.py`; `prompt_builder.py` keeps public delegate wrappers. diff --git a/docs/prompt-pool-routing-map.md b/docs/prompt-pool-routing-map.md index 12137e7..e4cb2bd 100644 --- a/docs/prompt-pool-routing-map.md +++ b/docs/prompt-pool-routing-map.md @@ -71,6 +71,7 @@ Core helper ownership: | `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. | | `row_item.py` | Row item selection, weighted item/pair choice, item-template axis filling, and oral/outercourse axis compatibility filters. | +| `row_route_metadata.py` | Row action/position route metadata resolution, template metadata precedence, inferred position-key merging, and source action-family fallback. | | `row_generation.py` | Built-in legacy row generation, auto-weighted/auto-full selection, row mode randomization, ratio clamps, and expression-intensity randomization. | | `category_cast_config.py` | Category preset and cast preset schemas, category/cast config JSON builders, choice lists, and config parsers used by route nodes. | | `cast_context.py` | Generation-time cast count phrases, configured-cast context metadata, character-slot label assignment, cast-summary wording, scene-kind labels, and couple count normalization. | @@ -473,9 +474,9 @@ plain prompt text. When debugging, inspect these fields before editing pools. | `item_axis_values` | `row_item.compose_item` | Krea hardcore rewrite, SDXL tags | Filled template axes such as position/action/detail values. | | `item_template_metadata` | `row_item.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. | | `formatter_hints` | `category_template_metadata.formatter_hints` | 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. | -| `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`. | +| `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`. | | `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 8e56d5c..36d0ff9 100644 --- a/prompt_builder.py +++ b/prompt_builder.py @@ -42,13 +42,13 @@ try: from . import row_item as row_item_policy from . import row_location as row_location_policy from . import row_pools as row_pool_policy + from . import row_route_metadata as row_route_policy from . import seed_config as seed_policy from . import subject_context as subject_context_policy from .hardcore_text_cleanup import ( 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_role_graphs import build_hardcore_role_graph except ImportError: # Allows local smoke tests with `python -c`. from category_library import ( @@ -86,13 +86,13 @@ except ImportError: # Allows local smoke tests with `python -c`. import row_item as row_item_policy import row_location as row_location_policy import row_pools as row_pool_policy + import row_route_metadata as row_route_policy import seed_config as seed_policy import subject_context as subject_context_policy from hardcore_text_cleanup import ( 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_role_graphs import build_hardcore_role_graph @@ -259,6 +259,31 @@ def _merge_position_keys(primary: list[str], fallback: list[str]) -> list[str]: return item_template_policy.merge_position_keys(primary, fallback) +def _action_position_route_metadata( + *, + is_pose_category: bool, + subcategory: dict[str, Any], + hardcore_position_config: dict[str, Any] | None, + item_template_metadata: dict[str, Any] | None, + item_text: Any, + source_role_graph: Any, + source_composition: Any, + pose: Any, + item_axis_values: dict[str, Any] | None = None, +) -> dict[str, Any]: + return row_route_policy.resolve_action_position_route( + is_pose_category=is_pose_category, + subcategory=subcategory, + hardcore_position_config=hardcore_position_config, + item_template_metadata=item_template_metadata, + item_text=item_text, + source_role_graph=source_role_graph, + source_composition=source_composition, + pose=pose, + item_axis_values=item_axis_values, + ) + + def _oral_acts_for_position(values: list[Any], position: str) -> list[Any]: return row_item_policy.oral_acts_for_position(values, position) @@ -2201,34 +2226,21 @@ 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: - 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 = _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, - ) + action_route = _action_position_route_metadata( + is_pose_category=is_pose_category, + subcategory=subcategory, + hardcore_position_config=parsed_hardcore_position_config, + item_template_metadata=item_template_metadata, + item_text=item_text, + source_role_graph=source_role_graph, + source_composition=source_composition, + pose=pose, + item_axis_values=item_axis_values, + ) + position_family = str(action_route.get("position_family") or "") + position_keys = list(action_route.get("position_keys") or []) + position_key = str(action_route.get("position_key") or "") + action_family = str(action_route.get("action_family") or "") 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)) diff --git a/row_route_metadata.py b/row_route_metadata.py new file mode 100644 index 0000000..0b11abd --- /dev/null +++ b/row_route_metadata.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +from typing import Any + +try: + from . import category_template_metadata as template_policy + from . import hardcore_position_config as hardcore_position_policy + from .hardcore_action_metadata import source_hardcore_action_family +except ImportError: # Allows local smoke tests from the repository root. + import category_template_metadata as template_policy + import hardcore_position_config as hardcore_position_policy + from hardcore_action_metadata import source_hardcore_action_family + + +EMPTY_ACTION_POSITION_ROUTE = { + "position_family": "", + "position_keys": [], + "position_key": "", + "action_family": "", +} + + +def empty_action_position_route() -> dict[str, Any]: + return { + "position_family": "", + "position_keys": [], + "position_key": "", + "action_family": "", + } + + +def resolve_action_position_route( + *, + is_pose_category: bool, + subcategory: dict[str, Any], + hardcore_position_config: dict[str, Any] | None, + item_template_metadata: dict[str, Any] | None, + item_text: Any, + source_role_graph: Any, + source_composition: Any, + pose: Any, + item_axis_values: dict[str, Any] | None = None, +) -> dict[str, Any]: + if not is_pose_category: + return empty_action_position_route() + + metadata = item_template_metadata or {} + position_family = template_policy.template_position_family( + metadata + ) or hardcore_position_policy.hardcore_source_position_family( + subcategory, + hardcore_position_config, + ) + inferred_position_keys = hardcore_position_policy.hardcore_position_keys( + item_text, + source_role_graph, + source_composition, + pose, + axis_values=item_axis_values, + ) + position_keys = template_policy.merge_position_keys( + template_policy.template_position_keys(metadata), + inferred_position_keys, + ) + action_family = template_policy.template_action_family(metadata) + if not action_family: + action_family = source_hardcore_action_family( + position_family, + source_role_graph, + item_text, + source_composition, + item_axis_values, + ) + + return { + "position_family": position_family, + "position_keys": position_keys, + "position_key": position_keys[0] if position_keys else "", + "action_family": action_family, + } diff --git a/tools/prompt_smoke.py b/tools/prompt_smoke.py index 233fe74..3773719 100644 --- a/tools/prompt_smoke.py +++ b/tools/prompt_smoke.py @@ -57,6 +57,7 @@ import row_generation # noqa: E402 import row_item # noqa: E402 import row_location # noqa: E402 import row_pools # noqa: E402 +import row_route_metadata # noqa: E402 import server_routes # noqa: E402 import sdxl_formatter # noqa: E402 import sdxl_presets # noqa: E402 @@ -1882,6 +1883,69 @@ def smoke_hardcore_position_config_policy() -> None: _expect(any("invalid formatter_hint" in error for error in invalid_errors), "Template metadata validation missed bad formatter hint value") +def smoke_row_route_metadata_policy() -> None: + template_metadata = { + "action_family": "oral", + "position_family": "oral", + "position_keys": ["kneeling", "open_thighs"], + } + route = row_route_metadata.resolve_action_position_route( + is_pose_category=True, + subcategory={"slug": "oral_sex"}, + hardcore_position_config={}, + item_template_metadata=template_metadata, + item_text="mouth contact in kneeling oral position", + source_role_graph="the woman kneels in front of the man", + source_composition="close kneeling oral composition", + pose="kneeling pose", + item_axis_values={"position": "kneeling oral position"}, + ) + _expect(route["action_family"] == "oral", "Route policy lost template action family") + _expect(route["position_family"] == "oral", "Route policy lost template position family") + _expect(route["position_key"] == "kneeling", "Route policy did not preserve first template position key") + _expect(route["position_keys"] == ["kneeling", "open_thighs"], "Route policy changed template position-key precedence") + + delegated = pb._action_position_route_metadata( + is_pose_category=True, + subcategory={"slug": "oral_sex"}, + hardcore_position_config={}, + item_template_metadata=template_metadata, + item_text="mouth contact in kneeling oral position", + source_role_graph="the woman kneels in front of the man", + source_composition="close kneeling oral composition", + pose="kneeling pose", + item_axis_values={"position": "kneeling oral position"}, + ) + _expect(delegated == route, "Prompt builder route wrapper should delegate to row_route_metadata") + + fallback = row_route_metadata.resolve_action_position_route( + is_pose_category=True, + subcategory={"slug": "manual_stimulation"}, + hardcore_position_config={}, + item_template_metadata={}, + item_text="manual stimulation while kneeling", + source_role_graph="the woman kneels close and uses her hand", + source_composition="kneeling manual composition", + pose="kneeling pose", + item_axis_values={"position": "kneeling manual position"}, + ) + _expect(fallback["position_family"] == "manual", "Route policy lost source position-family fallback") + _expect(fallback["action_family"] == "foreplay", "Route policy lost source action-family fallback") + _expect("kneeling" in fallback["position_keys"], "Route policy lost inferred position key") + + empty = row_route_metadata.resolve_action_position_route( + is_pose_category=False, + subcategory={"slug": "casual_clothes"}, + hardcore_position_config={}, + item_template_metadata=template_metadata, + item_text="casual outfit", + source_role_graph="", + source_composition="", + pose="standing pose", + ) + _expect(empty == row_route_metadata.empty_action_position_route(), "Non-pose route should return empty route metadata") + + def smoke_category_library_route() -> None: categories = category_library.load_category_library() _expect(len(categories) >= 3, "category library should load JSON categories") @@ -4138,6 +4202,7 @@ SMOKE_CASES: list[tuple[str, Callable[[], None]]] = [ ("caption_policy", smoke_caption_policy), ("sdxl_presets_policy", smoke_sdxl_presets_policy), ("hardcore_position_config_policy", smoke_hardcore_position_config_policy), + ("row_route_metadata_policy", smoke_row_route_metadata_policy), ("category_library_route", smoke_category_library_route), ("hardcore_category_routes", smoke_hardcore_category_routes), ("krea_close_foreplay_route", smoke_krea_close_foreplay_route),