diff --git a/hardcore_position_config.py b/hardcore_position_config.py index 49a31b8..4ba3848 100644 --- a/hardcore_position_config.py +++ b/hardcore_position_config.py @@ -5,6 +5,11 @@ import re from string import Formatter from typing import Any, Callable +try: + from . import item_axis_policy +except ImportError: # Allows local smoke tests with top-level imports. + import item_axis_policy + HARDCORE_POSITION_FAMILY_CHOICES = [ "any", @@ -847,10 +852,7 @@ def hardcore_source_position_family(subcategory: dict[str, Any], config: dict[st 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() + text = item_axis_policy.context_text(*parts, axis_values=axis_values) if not text: return [] keys: list[str] = [] diff --git a/hardcore_role_anal.py b/hardcore_role_anal.py index 20cc461..bfd4f24 100644 --- a/hardcore_role_anal.py +++ b/hardcore_role_anal.py @@ -2,15 +2,14 @@ from __future__ import annotations from typing import Any +try: + from . import item_axis_policy +except ImportError: # Allows local smoke tests with top-level imports. + import item_axis_policy + def _context_text(item_text: str, item_axis_values: dict[str, Any] | None) -> str: - return " ".join( - str(part or "").lower() - for part in ( - item_text, - *((item_axis_values or {}).values()), - ) - ) + return item_axis_policy.context_text(item_text, axis_values=item_axis_values) def _anal_position_graph(woman: str, man: str, context: str) -> str: diff --git a/hardcore_role_climax.py b/hardcore_role_climax.py index 1468e94..f4a3d93 100644 --- a/hardcore_role_climax.py +++ b/hardcore_role_climax.py @@ -3,15 +3,14 @@ from __future__ import annotations import re from typing import Any +try: + from . import item_axis_policy +except ImportError: # Allows local smoke tests with top-level imports. + import item_axis_policy + def _context_text(item_text: str, item_axis_values: dict[str, Any] | None) -> str: - return " ".join( - str(part or "").lower() - for part in ( - item_text, - *((item_axis_values or {}).values()), - ) - ) + return item_axis_policy.context_text(item_text, axis_values=item_axis_values) def _mentions_ass(text: str) -> bool: diff --git a/hardcore_role_graphs.py b/hardcore_role_graphs.py index ef00c0c..c6a06bf 100644 --- a/hardcore_role_graphs.py +++ b/hardcore_role_graphs.py @@ -4,6 +4,7 @@ import random from typing import Any try: + from . import item_axis_policy from .hardcore_role_anal import build_anal_or_double_role_graph from .hardcore_role_climax import build_climax_role_graph from .hardcore_role_fallback import ( @@ -23,6 +24,7 @@ try: from .hardcore_role_outercourse import build_outercourse_role_graph from .hardcore_role_penetration import build_penetration_role_graph except ImportError: # Allows local smoke tests with `python -c`. + import item_axis_policy from hardcore_role_anal import build_anal_or_double_role_graph from hardcore_role_climax import build_climax_role_graph from hardcore_role_fallback import ( @@ -85,7 +87,7 @@ def build_hardcore_role_graph( men = participants["men"] people = participants["people"] slug = str(subcategory.get("slug") or subcategory.get("name") or "").lower() - item_text = " ".join((item_axis_values or {}).values()).lower() + item_text = item_axis_policy.context_text(axis_values=item_axis_values) def any_person(exclude: set[str] | None = None) -> str: exclude = exclude or set() diff --git a/hardcore_role_interaction.py b/hardcore_role_interaction.py index bb5359a..f696d06 100644 --- a/hardcore_role_interaction.py +++ b/hardcore_role_interaction.py @@ -2,15 +2,14 @@ from __future__ import annotations from typing import Any +try: + from . import item_axis_policy +except ImportError: # Allows local smoke tests with top-level imports. + import item_axis_policy + def _context_text(item_text: str, item_axis_values: dict[str, Any] | None) -> str: - return " ".join( - str(part or "").lower() - for part in ( - item_text, - *((item_axis_values or {}).values()), - ) - ) + return item_axis_policy.context_text(item_text, axis_values=item_axis_values) def build_foreplay_role_graph( diff --git a/hardcore_role_oral.py b/hardcore_role_oral.py index 2ff612d..b2aaea3 100644 --- a/hardcore_role_oral.py +++ b/hardcore_role_oral.py @@ -2,15 +2,14 @@ from __future__ import annotations from typing import Any +try: + from . import item_axis_policy +except ImportError: # Allows local smoke tests with top-level imports. + import item_axis_policy + def _context_text(item_text: str, item_axis_values: dict[str, Any] | None) -> str: - return " ".join( - str(part or "").lower() - for part in ( - item_text, - *((item_axis_values or {}).values()), - ) - ) + return item_axis_policy.context_text(item_text, axis_values=item_axis_values) def _oral_direction(text: str) -> tuple[bool, bool]: @@ -54,7 +53,7 @@ def build_oral_role_graph( item_axis_values: dict[str, Any] | None = None, pov_labels: list[str] | None = None, ) -> str: - position_text = str((item_axis_values or {}).get("position") or "").lower() + position_text = item_axis_policy.key_text(item_axis_values, "position") text = _context_text(item_text, item_axis_values) man_is_pov = man in set(pov_labels or []) woman_gives, man_gives = _oral_direction(text) diff --git a/hardcore_role_outercourse.py b/hardcore_role_outercourse.py index 1233843..a6d8916 100644 --- a/hardcore_role_outercourse.py +++ b/hardcore_role_outercourse.py @@ -3,19 +3,15 @@ from __future__ import annotations from typing import Any try: + from . import item_axis_policy from . import outercourse_action_policy as outercourse_policy except ImportError: # Allows local smoke tests with top-level imports. + import item_axis_policy import outercourse_action_policy as outercourse_policy def _context_text(item_text: str, item_axis_values: dict[str, Any] | None) -> str: - return " ".join( - str(part or "").lower() - for part in ( - item_text, - *((item_axis_values or {}).values()), - ) - ) + return item_axis_policy.context_text(item_text, axis_values=item_axis_values) def build_outercourse_role_graph( @@ -25,7 +21,7 @@ def build_outercourse_role_graph( item_axis_values: dict[str, Any] | None = None, pov_labels: list[str] | None = None, ) -> str: - position_text = str((item_axis_values or {}).get("position") or "").lower() + position_text = item_axis_policy.key_text(item_axis_values, "position") text = _context_text(item_text, item_axis_values) action_kind = outercourse_policy.infer_outercourse_action_kind(position_text) if action_kind == outercourse_policy.OUTERCOURSE_GENERIC: diff --git a/hardcore_role_penetration.py b/hardcore_role_penetration.py index dfe0c95..7e2563a 100644 --- a/hardcore_role_penetration.py +++ b/hardcore_role_penetration.py @@ -2,15 +2,14 @@ from __future__ import annotations from typing import Any +try: + from . import item_axis_policy +except ImportError: # Allows local smoke tests with top-level imports. + import item_axis_policy + def _context_text(item_text: str, item_axis_values: dict[str, Any] | None) -> str: - return " ".join( - str(part or "").lower() - for part in ( - item_text, - *((item_axis_values or {}).values()), - ) - ) + return item_axis_policy.context_text(item_text, axis_values=item_axis_values) def build_penetration_role_graph( diff --git a/item_axis_policy.py b/item_axis_policy.py index 5afe38b..7fb9abd 100644 --- a/item_axis_policy.py +++ b/item_axis_policy.py @@ -108,6 +108,19 @@ def action_context_text(axis_values: Any) -> str: ) +def context_text(*parts: Any, axis_values: Any = None) -> str: + text_parts = [clean_text(part) for part in parts if clean_text(part)] + text_parts.extend(axis_value_texts(axis_values)) + return " ".join(part.lower() for part in text_parts if part) + + +def key_text(axis_values: Any, key: str) -> str: + if not isinstance(axis_values, dict): + return "" + values = value_texts(axis_values.get(key)) + return values[0].lower() if values else "" + + def row_axis_value_texts( row: dict[str, Any], *, diff --git a/pair_clothing.py b/pair_clothing.py index 18d9d0d..e2a9672 100644 --- a/pair_clothing.py +++ b/pair_clothing.py @@ -4,6 +4,11 @@ from dataclasses import dataclass import re from typing import Any, Callable +try: + from . import item_axis_policy +except ImportError: # Allows local smoke tests with top-level imports. + import item_axis_policy + WOMAN_LOWER_ACCESS_TERMS = ( "penetrat", @@ -237,7 +242,7 @@ def character_hardcore_clothing_entries( def hardcore_row_access_flags(row: dict[str, Any]) -> dict[str, bool]: axis_values = row.get("item_axis_values") - axis_text = " ".join(str(value) for value in axis_values.values()) if isinstance(axis_values, dict) else "" + axis_text = item_axis_policy.context_text(axis_values=axis_values) role_text = " ".join( str(part or "") for part in ( @@ -255,10 +260,11 @@ def hardcore_row_access_flags(row: dict[str, Any]) -> dict[str, bool]: ) ).lower() full_text = f"{role_text} {detail_text}" + lower_access_text = f"{role_text} {axis_text}" return { - "woman_lower": any(term in role_text for term in WOMAN_LOWER_ACCESS_TERMS), + "woman_lower": any(term in lower_access_text for term in WOMAN_LOWER_ACCESS_TERMS), "woman_upper": any(term in full_text for term in WOMAN_UPPER_ACCESS_TERMS), - "man_lower": any(term in role_text for term in MAN_LOWER_ACCESS_TERMS), + "man_lower": any(term in lower_access_text for term in MAN_LOWER_ACCESS_TERMS), } diff --git a/tools/prompt_smoke.py b/tools/prompt_smoke.py index ba8ac27..da97ed3 100644 --- a/tools/prompt_smoke.py +++ b/tools/prompt_smoke.py @@ -3439,6 +3439,22 @@ def smoke_row_role_graph_policy() -> None: pov_route.role_graph.startswith("First-person POV from Man A;"), "Role graph route did not prepend POV role graph directive", ) + structured_axis_route = row_role_graph.resolve_role_graph_route( + rng=random.Random(54), + subcategory={"slug": "oral", "name": "Oral"}, + context=context, + item_axis_values={ + "position": {"text": "standing oral position"}, + "oral_act": ["blowjob", "auto"], + "contact_detail": {"unused": "mouth contact at hip height"}, + }, + pov_character_labels=[], + is_pose_category=False, + ) + _expect( + "kneels in front" in structured_axis_route.source_role_graph, + "Role graph route should read structured/list axis values through item_axis_policy", + ) def smoke_row_assembly_policy() -> None: @@ -5876,6 +5892,24 @@ def smoke_pair_route_policy() -> None: partial_common = {**clothing_common, "mode": "partially_removed"} partial_route = pair_clothing.resolve_hardcore_pair_clothing_result(**partial_common) _expect(partial_route.requires_body_exposure_scene is True, "Partial lower-access clothing should request scene cleanup") + structured_axis_clothing = pair_clothing.resolve_hardcore_pair_clothing_result( + **{ + **clothing_common, + "hard_row": { + "role_graph": "generic adult action", + "item": "generic configured action", + "item_axis_values": { + "position": {"text": "edge-supported penetration"}, + "leg_detail": ["thighs open", "auto"], + }, + }, + "mode": "partially_removed", + } + ) + _expect( + structured_axis_clothing.woman_access == "lower", + "Pair clothing should read structured/list axis values for lower-access detection", + ) oral_common = { **clothing_common, "hard_row": {