From 3d9dbdc95db43bd658e10aff0aaf796ce30050fd Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Sat, 27 Jun 2026 08:56:35 +0200 Subject: [PATCH] Extract row expression policy --- docs/prompt-architecture-improvement-plan.md | 4 + docs/prompt-pool-routing-map.md | 11 +- prompt_builder.py | 240 ++------------- row_expression.py | 304 +++++++++++++++++++ tools/prompt_smoke.py | 75 +++++ 5 files changed, 412 insertions(+), 222 deletions(-) create mode 100644 row_expression.py diff --git a/docs/prompt-architecture-improvement-plan.md b/docs/prompt-architecture-improvement-plan.md index 96d058d..8d3aa74 100644 --- a/docs/prompt-architecture-improvement-plan.md +++ b/docs/prompt-architecture-improvement-plan.md @@ -169,6 +169,10 @@ Already isolated: runtime location/composition pool overrides, and generator fallback pool selection live in `row_pools.py`; `prompt_builder.py` keeps public delegate wrappers. +- row expression text cleanup, expression intensity weighting, + character-slot/cast expression override resolution, and per-character + expression picking live in `row_expression.py`; `prompt_builder.py` keeps + public delegate wrappers. - hardcore position/action-filter choices, selected-position normalization, config JSON builders/parsers, focus-policy toggles, subcategory allow-list policy, position-key detection, category filtering, and item-template/axis diff --git a/docs/prompt-pool-routing-map.md b/docs/prompt-pool-routing-map.md index 7f61848..e750cf0 100644 --- a/docs/prompt-pool-routing-map.md +++ b/docs/prompt-pool-routing-map.md @@ -82,6 +82,7 @@ Core helper ownership: | `subject_context.py` | Row subject-context routing for single, couple, configured-cast, group, and layout subjects, combining appearance policy, cast metadata, and generator subject pools. | | `location_config.py` | Location/composition preset schemas, themed location packs, custom location/composition parsing, pool merge behavior, and location/composition config parsing. | | `row_location.py` | Built-in row location/composition config application, deterministic scene/composition choice, source metadata, and legacy prompt/caption rewrites. | +| `row_expression.py` | Row expression cleanup, expression intensity weighting, character-slot/cast expression override resolution, and per-character expression selection. | | `row_pools.py` | Row scene/expression/pose/composition pool routing, category inheritance handling, runtime location/composition pool overrides, and generator fallback pools. | | `hardcore_position_config.py` | Hardcore position/action-filter choices, selected-position normalization, config JSON builders/parsers, focus-policy toggles, subcategory allow-list policy, position-key detection, and category/template/axis filtering. | | `pair_options.py` | Insta/OF option schema/defaults, softcore category/outfit/pose pools, partner outfit pools, clothing-continuity labels, negatives, hardcore cast count policy, and hardcore detail-density directives. | @@ -299,8 +300,8 @@ Edit targets: ### Expression -Expression text is selected by `_expression_pool`, then filtered by -`_expression_entries_for_intensity`. +Expression text is selected by `_expression_pool`, then filtered through +`row_expression.expression_entries_for_intensity`. Resolution order: @@ -315,7 +316,7 @@ Edit targets: - General expression pools: `categories/expression_composition_pools.json`. - Hardcore-specific expressions: usually `categories/sexual_poses.json` or named hardcore expression pools. -- Character-level expression settings: slot config and `_cast_expression_intensity_override`. +- Character-level expression settings: slot config and `row_expression.py`. - Formatter expression wording: `krea_formatter.py` or `caption_naturalizer.py`. ### Pose / Action @@ -842,8 +843,8 @@ pair metadata through the core Python APIs, then verifies: | Wrong location | `categories/location_pools.json`, category `scene_pool`, `_scene_pool`. | | Location good but camera/location layout wrong | `_camera_scene_directive_for_context`, coworking adapter functions. | | Repeated desk/anchor in POV foreground | Coworking direction/distance/elevation helpers. | -| Wrong expression intensity | Character slot expression settings, `_expression_entries_for_intensity`, expression pools. | -| Expression appears when disabled | `_disable_row_expression`, formatter expression extraction. | +| Wrong expression intensity | Character slot expression settings, `row_expression.py`, expression pools. | +| Expression appears when disabled | `row_expression.disable_row_expression`, formatter expression extraction. | | Same hardcore action repeats | Hardcore filter config, `sexual_poses.json` weights, `hardcore_position_config.apply_hardcore_position_config_to_subcategory`. | | Hardcore interaction beat falls back to penetration/oral | `sexual_poses.json` interaction subcategory, `_role_graph`, and `krea_action_context.is_foreplay_text` / `krea_action_positions.hardcore_pose_anchor`. | | Raw hardcore prompt position is vague | `sexual_poses.json` item templates and role graph templates. | diff --git a/prompt_builder.py b/prompt_builder.py index 941e671..f5af664 100644 --- a/prompt_builder.py +++ b/prompt_builder.py @@ -41,6 +41,7 @@ try: from . import pov_policy from . import row_normalization as row_policy from . import row_camera as row_camera_policy + from . import row_expression as row_expression_policy from . import row_location as row_location_policy from . import row_pools as row_pool_policy from . import seed_config as seed_policy @@ -85,6 +86,7 @@ except ImportError: # Allows local smoke tests with `python -c`. import pov_policy import row_normalization as row_policy import row_camera as row_camera_policy + import row_expression as row_expression_policy import row_location as row_location_policy import row_pools as row_pool_policy import seed_config as seed_policy @@ -1135,44 +1137,15 @@ def _format(template: str, context: dict[str, Any]) -> str: def _clean_prompt_punctuation(text: str) -> str: - text = re.sub(r"\s+", " ", str(text or "")).strip() - text = re.sub(r"\s+([,.;:])", r"\1", text) - text = re.sub(r"(?:,\s*){2,}", ", ", text) - text = re.sub(r"\.\s*\.", ".", text) - text = re.sub(r":\s*\.", ".", text) - return text.strip() + return row_expression_policy.clean_prompt_punctuation(text) def _strip_expression_text(text: str, expression: Any = "") -> str: - text = str(text or "") - if not text: - return "" - text = re.sub(r"\s*Facial expressions?:\s*[^.]*\.\s*", " ", text, flags=re.IGNORECASE) - text = re.sub(r",\s*one with [^,]+ and the other with [^,]+(?=,)", "", text, flags=re.IGNORECASE) - text = re.sub(r",\s*a lively mix of expressions from [^,]+(?=,)", "", text, flags=re.IGNORECASE) - text = re.sub(r"\s+with\s+(?:an?|the)\s+[^,]*expression(?=,)", "", text, flags=re.IGNORECASE) - expression_text = str(expression or "").strip() - if expression_text: - for part in [piece.strip() for piece in expression_text.split(";") if piece.strip()]: - escaped = re.escape(part) - text = re.sub(rf",\s*{escaped}(?=,)", "", text, flags=re.IGNORECASE) - text = re.sub(rf"\s+with\s+(?:an?|the)?\s*{escaped}", "", text, flags=re.IGNORECASE) - return _clean_prompt_punctuation(text) + return row_expression_policy.strip_expression_text(text, expression) def _disable_row_expression(row: dict[str, Any], source: str = "disabled") -> dict[str, Any]: - previous_expression = row.get("expression", "") - row["prompt"] = _strip_expression_text(row.get("prompt", ""), previous_expression) - row["caption"] = _strip_expression_text(row.get("caption", ""), previous_expression) - row["expression"] = "" - row["shared_expression"] = "" - row["character_expressions"] = [] - row["character_expression_text"] = "" - row["expression_enabled"] = False - row["expression_disabled"] = True - row["expression_intensity"] = None - row["expression_intensity_source"] = source - return row + return row_expression_policy.disable_row_expression(row, source) def _prepend_trigger(prompt: str, trigger: str, enabled: bool) -> str: @@ -1837,10 +1810,6 @@ def _slot_effective_figure( return character_slot_policy.slot_effective_figure(slot, subject_type, fallback_figure) -def _mean(values: list[float]) -> float: - return sum(values) / len(values) - - def _cast_expression_intensity_override( fallback: float, label_map: dict[str, dict[str, Any]], @@ -1848,35 +1817,13 @@ def _cast_expression_intensity_override( men_count: int, expression_phase: str = "", ) -> tuple[float | None, str]: - groups: list[tuple[str, list[str]]] = [ - ("women", [f"Woman {chr(ord('A') + index)}" for index in range(max(0, women_count))]), - ("men", [f"Man {chr(ord('A') + index)}" for index in range(max(0, men_count))]), - ] - all_values: list[float] = [] - matching_slots: list[dict[str, Any]] = [] - for group_name, labels in groups: - values: list[float] = [] - value_labels: list[str] = [] - for label in labels: - slot = label_map.get(label) - if _slot_is_pov(slot): - continue - if slot: - matching_slots.append(slot) - value = _slot_expression_intensity_for_phase(slot, expression_phase) - if value is not None: - values.append(value) - value_labels.append(label) - all_values.append(value) - if values: - if len(values) == 1: - return values[0], f"character_slot:{value_labels[0]}" - return _mean(values), f"character_slots:{group_name}" - if all_values: - return _mean(all_values), "character_slots:cast" - if matching_slots and all(not _slot_expression_enabled(slot) for slot in matching_slots): - return None, "character_slots:disabled" - return fallback, "input" + return row_expression_policy.cast_expression_intensity_override( + fallback, + label_map, + women_count, + men_count, + expression_phase, + ) def _character_expression_entries( @@ -1888,41 +1835,15 @@ def _character_expression_entries( men_count: int, expression_phase: str = "", ) -> list[str]: - labels = [ - *[f"Woman {chr(ord('A') + index)}" for index in range(max(0, women_count))], - *[f"Man {chr(ord('A') + index)}" for index in range(max(0, men_count))], - ] - expressions: list[str] = [] - used: set[str] = set() - for label in labels: - slot = label_map.get(label) - if not slot: - continue - if _slot_is_pov(slot): - continue - if not _slot_expression_enabled(slot): - continue - intensity = _slot_expression_intensity_for_phase(slot, expression_phase) - if intensity is None: - intensity = fallback_intensity - entries = _compatible_entries( - _expression_entries_for_intensity(expression_pool, intensity), - women_count, - men_count, - ) - if not entries: - continue - choice = "" - for _attempt in range(5): - candidate = _choose_text(rng, entries) - if candidate not in used: - choice = candidate - break - if not choice: - choice = _choose_text(rng, entries) - used.add(choice) - expressions.append(f"{label} has {choice}") - return expressions + return row_expression_policy.character_expression_entries( + rng, + expression_pool, + fallback_intensity, + label_map, + women_count, + men_count, + expression_phase, + ) def _sanitize_character_expression_text_for_action( @@ -2521,126 +2442,11 @@ def _expression_pool(category: dict[str, Any], subcategory: dict[str, Any], item def _expression_intensity_hint(entry: Any) -> float: - if isinstance(entry, dict): - for key in ("expression_intensity", "intensity"): - if key in entry: - return _clamped_float(entry[key], 0.5) - - text = _entry_text(entry).lower() - high_terms = ( - "ahegao", - "orgasm", - "climax", - "drool", - "drooling", - "tongue out", - "eyes rolled", - "fucked-out", - "cum-smeared", - "saliva", - "gagging", - "slack jaw", - "jaw slack", - "slack-jawed", - "sex-drunk", - "overwhelmed", - "strained", - "messy", - "panting", - "trembling", - "shaking", - "wide open mouth", - "raw ", - "wild ", - "dazed", - "spent", - ) - if any(term in text for term in high_terms): - return 0.9 - - medium_terms = ( - "seductive", - "teasing", - "lustful", - "aroused", - "bedroom", - "dominant", - "predatory", - "control", - "stern", - "strict", - "smirk", - "parted lips", - "open-mouthed", - "heated", - "hungry", - "inviting", - "sensual", - "fetish", - "commanding", - "flushed", - "moan", - ) - if any(term in text for term in medium_terms): - return 0.62 - - low_terms = ( - "neutral", - "quiet", - "calm", - "reserved", - "relaxed", - "candid", - "closed-mouth", - "thoughtful", - "controlled", - "focused", - "steady", - "bitten-lip", - "braced", - "held breath", - "concentrated", - "aloof", - "bored", - "tired", - "unfocused", - "contented", - "fashion", - "soft", - "sleepy", - "fresh-faced", - ) - if any(term in text for term in low_terms): - return 0.25 - return 0.5 + return row_expression_policy.expression_intensity_hint(entry) def _expression_entries_for_intensity(entries: list[Any], expression_intensity: float) -> list[Any]: - target = _clamped_float(expression_intensity, 0.5) - weighted: list[Any] = [] - for entry in entries: - entry_intensity = _expression_intensity_hint(entry) - distance = abs(target - entry_intensity) - if distance <= 0.18: - intensity_weight = 4.0 - elif distance <= 0.35: - intensity_weight = 1.4 - elif distance <= 0.55: - intensity_weight = 0.35 - else: - intensity_weight = 0.05 - - if isinstance(entry, dict): - adjusted = dict(entry) - try: - base_weight = float(adjusted.get("weight", 1.0)) - except (TypeError, ValueError): - base_weight = 1.0 - adjusted["weight"] = max(0.0, base_weight) * intensity_weight - weighted.append(adjusted) - else: - weighted.append({"text": _entry_text(entry), "weight": intensity_weight}) - return weighted or entries + return row_expression_policy.expression_entries_for_intensity(entries, expression_intensity) def _pose_pool(category: dict[str, Any], subcategory: dict[str, Any], item: Any, subject_type: str, poses: str) -> list[Any]: diff --git a/row_expression.py b/row_expression.py new file mode 100644 index 0000000..a29327f --- /dev/null +++ b/row_expression.py @@ -0,0 +1,304 @@ +from __future__ import annotations + +import random +import re +from typing import Any + +try: + from . import category_library as category_policy + from . import character_slot as character_slot_policy + from . import pov_policy +except ImportError: # Allows local smoke tests with top-level imports. + import category_library as category_policy + import character_slot as character_slot_policy + import pov_policy + + +def clean_prompt_punctuation(text: str) -> str: + text = re.sub(r"\s+", " ", str(text or "")).strip() + text = re.sub(r"\s+([,.;:])", r"\1", text) + text = re.sub(r"(?:,\s*){2,}", ", ", text) + text = re.sub(r"\.\s*\.", ".", text) + text = re.sub(r":\s*\.", ".", text) + return text.strip() + + +def strip_expression_text(text: str, expression: Any = "") -> str: + text = str(text or "") + if not text: + return "" + text = re.sub(r"\s*Facial expressions?:\s*[^.]*\.\s*", " ", text, flags=re.IGNORECASE) + text = re.sub(r",\s*one with [^,]+ and the other with [^,]+(?=,)", "", text, flags=re.IGNORECASE) + text = re.sub(r",\s*a lively mix of expressions from [^,]+(?=,)", "", text, flags=re.IGNORECASE) + text = re.sub(r"\s+with\s+(?:an?|the)\s+[^,]*expression(?=,)", "", text, flags=re.IGNORECASE) + expression_text = str(expression or "").strip() + if expression_text: + for part in [piece.strip() for piece in expression_text.split(";") if piece.strip()]: + escaped = re.escape(part) + text = re.sub(rf",\s*{escaped}(?=,)", "", text, flags=re.IGNORECASE) + text = re.sub(rf"\s+with\s+(?:an?|the)?\s*{escaped}", "", text, flags=re.IGNORECASE) + return clean_prompt_punctuation(text) + + +def disable_row_expression(row: dict[str, Any], source: str = "disabled") -> dict[str, Any]: + previous_expression = row.get("expression", "") + row["prompt"] = strip_expression_text(row.get("prompt", ""), previous_expression) + row["caption"] = strip_expression_text(row.get("caption", ""), previous_expression) + row["expression"] = "" + row["shared_expression"] = "" + row["character_expressions"] = [] + row["character_expression_text"] = "" + row["expression_enabled"] = False + row["expression_disabled"] = True + row["expression_intensity"] = None + row["expression_intensity_source"] = source + return row + + +def _clamped_float(value: Any, default: float = 0.5, min_value: float = 0.0, max_value: float = 1.0) -> float: + try: + number = float(value) + except (TypeError, ValueError): + return default + return max(min_value, min(max_value, number)) + + +def _entry_text(entry: Any) -> str: + return category_policy._entry_text(entry) + + +def expression_intensity_hint(entry: Any) -> float: + if isinstance(entry, dict): + for key in ("expression_intensity", "intensity"): + if key in entry: + return _clamped_float(entry[key], 0.5) + + text = _entry_text(entry).lower() + high_terms = ( + "ahegao", + "orgasm", + "climax", + "drool", + "drooling", + "tongue out", + "eyes rolled", + "fucked-out", + "cum-smeared", + "saliva", + "gagging", + "slack jaw", + "jaw slack", + "slack-jawed", + "sex-drunk", + "overwhelmed", + "strained", + "messy", + "panting", + "trembling", + "shaking", + "wide open mouth", + "raw ", + "wild ", + "dazed", + "spent", + ) + if any(term in text for term in high_terms): + return 0.9 + + medium_terms = ( + "seductive", + "teasing", + "lustful", + "aroused", + "bedroom", + "dominant", + "predatory", + "control", + "stern", + "strict", + "smirk", + "parted lips", + "open-mouthed", + "heated", + "hungry", + "inviting", + "sensual", + "fetish", + "commanding", + "flushed", + "moan", + ) + if any(term in text for term in medium_terms): + return 0.62 + + low_terms = ( + "neutral", + "quiet", + "calm", + "reserved", + "relaxed", + "candid", + "closed-mouth", + "thoughtful", + "controlled", + "focused", + "steady", + "bitten-lip", + "braced", + "held breath", + "concentrated", + "aloof", + "bored", + "tired", + "unfocused", + "contented", + "fashion", + "soft", + "sleepy", + "fresh-faced", + ) + if any(term in text for term in low_terms): + return 0.25 + return 0.5 + + +def expression_entries_for_intensity(entries: list[Any], expression_intensity: float) -> list[Any]: + target = _clamped_float(expression_intensity, 0.5) + weighted: list[Any] = [] + for entry in entries: + entry_intensity = expression_intensity_hint(entry) + distance = abs(target - entry_intensity) + if distance <= 0.18: + intensity_weight = 4.0 + elif distance <= 0.35: + intensity_weight = 1.4 + elif distance <= 0.55: + intensity_weight = 0.35 + else: + intensity_weight = 0.05 + + if isinstance(entry, dict): + adjusted = dict(entry) + try: + base_weight = float(adjusted.get("weight", 1.0)) + except (TypeError, ValueError): + base_weight = 1.0 + adjusted["weight"] = max(0.0, base_weight) * intensity_weight + weighted.append(adjusted) + else: + weighted.append({"text": _entry_text(entry), "weight": intensity_weight}) + return weighted or entries + + +def _mean(values: list[float]) -> float: + return sum(values) / len(values) + + +def cast_expression_intensity_override( + fallback: float, + label_map: dict[str, dict[str, Any]], + women_count: int, + men_count: int, + expression_phase: str = "", +) -> tuple[float | None, str]: + groups: list[tuple[str, list[str]]] = [ + ("women", [f"Woman {chr(ord('A') + index)}" for index in range(max(0, women_count))]), + ("men", [f"Man {chr(ord('A') + index)}" for index in range(max(0, men_count))]), + ] + all_values: list[float] = [] + matching_slots: list[dict[str, Any]] = [] + for group_name, labels in groups: + values: list[float] = [] + value_labels: list[str] = [] + for label in labels: + slot = label_map.get(label) + if pov_policy.slot_is_pov(slot): + continue + if slot: + matching_slots.append(slot) + value = character_slot_policy.slot_expression_intensity_for_phase(slot, expression_phase) + if value is not None: + values.append(value) + value_labels.append(label) + all_values.append(value) + if values: + if len(values) == 1: + return values[0], f"character_slot:{value_labels[0]}" + return _mean(values), f"character_slots:{group_name}" + if all_values: + return _mean(all_values), "character_slots:cast" + if matching_slots and all(not character_slot_policy.slot_expression_enabled(slot) for slot in matching_slots): + return None, "character_slots:disabled" + return fallback, "input" + + +def _weighted_choice(rng: random.Random, items: list[Any]) -> Any: + if not items: + raise ValueError("Cannot choose from an empty list") + weights: list[float] = [] + for item in items: + weight = item.get("weight", 1.0) if isinstance(item, dict) else 1.0 + try: + weights.append(max(0.0, float(weight))) + except (TypeError, ValueError): + weights.append(1.0) + total = sum(weights) + if total <= 0: + return items[rng.randrange(len(items))] + pick = rng.random() * total + running = 0.0 + for item, weight in zip(items, weights): + running += weight + if pick <= running: + return item + return items[-1] + + +def _choose_text(rng: random.Random, items: list[Any]) -> str: + return _entry_text(_weighted_choice(rng, items)) + + +def character_expression_entries( + rng: random.Random, + expression_pool: list[Any], + fallback_intensity: float, + label_map: dict[str, dict[str, Any]], + women_count: int, + men_count: int, + expression_phase: str = "", +) -> list[str]: + labels = [ + *[f"Woman {chr(ord('A') + index)}" for index in range(max(0, women_count))], + *[f"Man {chr(ord('A') + index)}" for index in range(max(0, men_count))], + ] + expressions: list[str] = [] + used: set[str] = set() + for label in labels: + slot = label_map.get(label) + if not slot: + continue + if pov_policy.slot_is_pov(slot): + continue + if not character_slot_policy.slot_expression_enabled(slot): + continue + intensity = character_slot_policy.slot_expression_intensity_for_phase(slot, expression_phase) + if intensity is None: + intensity = fallback_intensity + entries = category_policy.compatible_entries( + expression_entries_for_intensity(expression_pool, intensity), + women_count, + men_count, + ) + if not entries: + continue + choice = "" + for _attempt in range(5): + candidate = _choose_text(rng, entries) + if candidate not in used: + choice = candidate + break + if not choice: + choice = _choose_text(rng, entries) + used.add(choice) + expressions.append(f"{label} has {choice}") + return expressions diff --git a/tools/prompt_smoke.py b/tools/prompt_smoke.py index e440be4..25aaf28 100644 --- a/tools/prompt_smoke.py +++ b/tools/prompt_smoke.py @@ -51,6 +51,7 @@ import pov_policy # noqa: E402 import row_normalization # noqa: E402 import route_metadata # noqa: E402 import row_camera # noqa: E402 +import row_expression # noqa: E402 import row_location # noqa: E402 import row_pools # noqa: E402 import server_routes # noqa: E402 @@ -658,6 +659,79 @@ def smoke_row_location_policy() -> None: _expect(", long archive aisle composition," in updated.get("caption", ""), "Row location policy did not rewrite caption composition") +def smoke_row_expression_policy() -> None: + entries = [ + {"text": "quiet calm focus", "weight": 2.0}, + {"text": "heated smirk", "weight": 3.0}, + {"text": "dazed overwhelmed look", "weight": 1.0}, + ] + _expect( + pb._expression_entries_for_intensity(entries, 0.25) == row_expression.expression_entries_for_intensity(entries, 0.25), + "Prompt builder expression intensity wrapper should delegate to row_expression", + ) + weighted = row_expression.expression_entries_for_intensity(entries, 0.25) + _expect(weighted[0]["weight"] == 8.0, "Row expression low-intensity weighting changed") + _expect(weighted[2]["weight"] == 0.05, "Row expression distant-intensity weighting changed") + + row = { + "prompt": "Person with quiet focus, in room. Facial expression: quiet focus. Keep this.", + "caption": "sxcppnl7, person with quiet focus, in room", + "expression": "quiet focus", + "shared_expression": "quiet focus", + "character_expressions": ["Woman A has quiet focus"], + "character_expression_text": "Woman A has quiet focus", + } + _expect( + pb._disable_row_expression(dict(row), "test") == row_expression.disable_row_expression(dict(row), "test"), + "Prompt builder expression disable wrapper should delegate to row_expression", + ) + disabled = row_expression.disable_row_expression(dict(row), "test") + _expect(disabled.get("expression_disabled") is True, "Row expression disable did not set disabled metadata") + _expect("quiet focus" not in disabled.get("prompt", ""), "Row expression disable did not strip prompt expression text") + + woman_slot = character_slot.normalize_character_slot( + { + "subject_type": "woman", + "label": "A", + "expression_intensity": 0.8, + "softcore_expression_intensity": 0.2, + } + ) + pov_man_slot = character_slot.normalize_character_slot( + { + "subject_type": "man", + "label": "A", + "presence_mode": "pov", + "expression_intensity": 1.0, + } + ) + label_map = {"Woman A": woman_slot, "Man A": pov_man_slot} + _expect( + pb._cast_expression_intensity_override(0.5, label_map, 1, 1, "softcore") + == row_expression.cast_expression_intensity_override(0.5, label_map, 1, 1, "softcore"), + "Prompt builder cast expression override wrapper should delegate to row_expression", + ) + _expect( + row_expression.cast_expression_intensity_override(0.5, label_map, 1, 1, "softcore") + == (0.2, "character_slot:Woman A"), + "Row expression cast override did not prefer visible slot phase intensity", + ) + _expect( + pb._character_expression_entries(random.Random(22), entries, 0.5, label_map, 1, 1, "softcore") + == row_expression.character_expression_entries(random.Random(22), entries, 0.5, label_map, 1, 1, "softcore"), + "Prompt builder character expression wrapper should delegate to row_expression", + ) + + disabled_slot = character_slot.normalize_character_slot( + {"subject_type": "woman", "label": "A", "expression_enabled": False} + ) + _expect( + row_expression.cast_expression_intensity_override(0.5, {"Woman A": disabled_slot}, 1, 0, "hardcore") + == (None, "character_slots:disabled"), + "Row expression cast override did not honor all-slot expression disable", + ) + + def smoke_category_cast_config_policy() -> None: _expect(pb.CATEGORY_PRESETS is category_cast_config.CATEGORY_PRESETS, "Prompt builder category presets are not delegated") _expect(pb.CAST_PRESETS is category_cast_config.CAST_PRESETS, "Prompt builder cast presets are not delegated") @@ -3856,6 +3930,7 @@ SMOKE_CASES: list[tuple[str, Callable[[], None]]] = [ ("config_route_location_theme", smoke_config_route_location_theme), ("location_config_policy", smoke_location_config_policy), ("row_location_policy", smoke_row_location_policy), + ("row_expression_policy", smoke_row_expression_policy), ("category_cast_config_policy", smoke_category_cast_config_policy), ("generation_profile_config_policy", smoke_generation_profile_config_policy), ("filter_config_policy", smoke_filter_config_policy),