Extract row route metadata policy

This commit is contained in:
2026-06-27 09:27:39 +02:00
parent b46b709e8a
commit 55fec890a5
5 changed files with 194 additions and 33 deletions
@@ -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.
+4 -3
View File
@@ -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. |
+41 -29
View File
@@ -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))
+80
View File
@@ -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,
}
+65
View File
@@ -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),