diff --git a/docs/prompt-architecture-improvement-plan.md b/docs/prompt-architecture-improvement-plan.md index 82ffca3..620eba7 100644 --- a/docs/prompt-architecture-improvement-plan.md +++ b/docs/prompt-architecture-improvement-plan.md @@ -195,10 +195,11 @@ Already isolated: filtering, expression-disabled handling, per-character expression promotion, POV composition adaptation, and pose-category environment sanitizing live in `row_prompt_axes.py`; `prompt_builder.py` keeps a public delegate wrapper. -- row expression text cleanup, expression intensity weighting, - character-slot/cast expression override resolution, and per-character - expression picking plus action-aware character-expression sanitizing live in - `row_expression.py`; `prompt_builder.py` keeps public delegate wrappers. +- row expression text cleanup, expression route resolution, expression + intensity weighting, character-slot/cast expression override resolution, and + per-character expression picking plus action-aware character-expression + sanitizing 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 83ed26c..6d9d595 100644 --- a/docs/prompt-pool-routing-map.md +++ b/docs/prompt-pool-routing-map.md @@ -90,7 +90,7 @@ Core helper ownership: | `row_subject_route.py` | Row subject route orchestration, character slot/profile precedence, configured-cast POV labels, visible cast descriptor collection, and descriptor prompt cleanup. | | `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, per-character expression selection, and action-aware character-expression sanitizing. | +| `row_expression.py` | Row expression cleanup, expression route resolution, expression intensity weighting, character-slot/cast expression override resolution, per-character expression selection, and action-aware character-expression sanitizing. | | `row_pools.py` | Row scene/expression/pose/composition pool routing, category inheritance handling, runtime location/composition pool overrides, and generator fallback pools. | | `row_prompt_axes.py` | Row scene/pose/expression/composition axis selection, compatible-entry filtering, expression-disabled handling, per-character expression promotion, POV composition adaptation, and pose-category environment sanitizing. | | `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. | diff --git a/prompt_builder.py b/prompt_builder.py index 6cc6eb0..12b4b28 100644 --- a/prompt_builder.py +++ b/prompt_builder.py @@ -1446,6 +1446,33 @@ def _cast_expression_intensity_override( ) +def _resolve_expression_route( + *, + expression_enabled: bool, + expression_intensity: float, + expression_intensity_source: str, + subject_type: str, + applied_slot: dict[str, Any] | None = None, + character_slots: list[dict[str, Any]] | None = None, + character_slot_map: dict[str, dict[str, Any]] | None = None, + women_count: int = 1, + men_count: int = 1, + expression_phase: str = "", +) -> row_expression_policy.ExpressionRoute: + return row_expression_policy.resolve_expression_route( + expression_enabled=expression_enabled, + expression_intensity=expression_intensity, + expression_intensity_source=expression_intensity_source, + subject_type=subject_type, + applied_slot=applied_slot, + character_slots=character_slots, + character_slot_map=character_slot_map, + women_count=women_count, + men_count=men_count, + expression_phase=expression_phase, + ) + + def _character_expression_entries( rng: random.Random, expression_pool: list[Any], @@ -2176,30 +2203,21 @@ def _build_custom_row( if is_pose_category: source_role_graph = _sanitize_hardcore_environment_anchors(source_role_graph) role_graph = _pov_role_graph_prompt(source_role_graph, pov_character_labels) - expression_intensity_source = expression_intensity_source or "input" - expression_disabled = not bool(expression_enabled) - if expression_disabled: - expression_intensity_source = "disabled" - elif subject_type in ("woman", "man") and applied_slot: - slot_label = "Woman A" if subject_type == "woman" else "Man A" - if not _slot_expression_enabled(applied_slot): - expression_disabled = True - expression_intensity_source = f"character_slot:{slot_label}:disabled" - else: - slot_expression_intensity = _slot_expression_intensity_for_phase(applied_slot, expression_phase) - if slot_expression_intensity is not None: - expression_intensity = slot_expression_intensity - expression_intensity_source = f"character_slot:{slot_label}" - elif subject_type == "configured_cast" and character_slots: - expression_intensity, expression_intensity_source = _cast_expression_intensity_override( - expression_intensity, - character_slot_map, - women_count, - men_count, - expression_phase, - ) - if expression_intensity is None: - expression_disabled = True + expression_route = _resolve_expression_route( + expression_enabled=expression_enabled, + expression_intensity=expression_intensity, + expression_intensity_source=expression_intensity_source, + subject_type=subject_type, + applied_slot=applied_slot, + character_slots=character_slots, + character_slot_map=character_slot_map, + women_count=women_count, + men_count=men_count, + expression_phase=expression_phase, + ) + expression_disabled = expression_route.expression_disabled + expression_intensity = expression_route.expression_intensity + expression_intensity_source = expression_route.expression_intensity_source prompt_axes = _prompt_axes_route( category=category, diff --git a/row_expression.py b/row_expression.py index f330ce6..fd8bab9 100644 --- a/row_expression.py +++ b/row_expression.py @@ -1,5 +1,6 @@ from __future__ import annotations +from dataclasses import dataclass import random import re from typing import Any @@ -55,6 +56,61 @@ def disable_row_expression(row: dict[str, Any], source: str = "disabled") -> dic return row +@dataclass(frozen=True) +class ExpressionRoute: + expression_disabled: bool + expression_intensity: float | None + expression_intensity_source: str + + +def resolve_expression_route( + *, + expression_enabled: bool, + expression_intensity: float, + expression_intensity_source: str, + subject_type: str, + applied_slot: dict[str, Any] | None = None, + character_slots: list[dict[str, Any]] | None = None, + character_slot_map: dict[str, dict[str, Any]] | None = None, + women_count: int = 1, + men_count: int = 1, + expression_phase: str = "", +) -> ExpressionRoute: + source = expression_intensity_source or "input" + disabled = not bool(expression_enabled) + intensity: float | None = expression_intensity + if disabled: + source = "disabled" + elif subject_type in ("woman", "man") and applied_slot: + slot_label = "Woman A" if subject_type == "woman" else "Man A" + if not character_slot_policy.slot_expression_enabled(applied_slot): + disabled = True + source = f"character_slot:{slot_label}:disabled" + else: + slot_expression_intensity = character_slot_policy.slot_expression_intensity_for_phase( + applied_slot, + expression_phase, + ) + if slot_expression_intensity is not None: + intensity = slot_expression_intensity + source = f"character_slot:{slot_label}" + elif subject_type == "configured_cast" and character_slots: + intensity, source = cast_expression_intensity_override( + expression_intensity, + character_slot_map or {}, + women_count, + men_count, + expression_phase, + ) + if intensity is None: + disabled = True + return ExpressionRoute( + expression_disabled=disabled, + expression_intensity=intensity, + expression_intensity_source=source, + ) + + def _clamped_float(value: Any, default: float = 0.5, min_value: float = 0.0, max_value: float = 1.0) -> float: try: number = float(value) diff --git a/tools/prompt_smoke.py b/tools/prompt_smoke.py index 9ca745e..f9f9403 100644 --- a/tools/prompt_smoke.py +++ b/tools/prompt_smoke.py @@ -725,6 +725,41 @@ def smoke_row_expression_policy() -> None: == (0.2, "character_slot:Woman A"), "Row expression cast override did not prefer visible slot phase intensity", ) + _expect( + pb._resolve_expression_route( + expression_enabled=True, + expression_intensity=0.5, + expression_intensity_source="input", + subject_type="woman", + applied_slot=woman_slot, + women_count=1, + men_count=0, + expression_phase="softcore", + ) + == row_expression.resolve_expression_route( + expression_enabled=True, + expression_intensity=0.5, + expression_intensity_source="input", + subject_type="woman", + applied_slot=woman_slot, + women_count=1, + men_count=0, + expression_phase="softcore", + ), + "Prompt builder expression route wrapper should delegate to row_expression", + ) + route = row_expression.resolve_expression_route( + expression_enabled=True, + expression_intensity=0.5, + expression_intensity_source="input", + subject_type="woman", + applied_slot=woman_slot, + women_count=1, + men_count=0, + expression_phase="softcore", + ) + _expect(route.expression_intensity == 0.2, "Expression route did not apply phase-specific slot intensity") + _expect(route.expression_intensity_source == "character_slot:Woman A", "Expression route lost slot source") _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"), @@ -739,6 +774,45 @@ def smoke_row_expression_policy() -> None: == (None, "character_slots:disabled"), "Row expression cast override did not honor all-slot expression disable", ) + global_disabled = row_expression.resolve_expression_route( + expression_enabled=False, + expression_intensity=0.8, + expression_intensity_source="input", + subject_type="woman", + applied_slot=woman_slot, + ) + _expect(global_disabled.expression_disabled is True, "Expression route did not honor global disabled state") + _expect(global_disabled.expression_intensity == 0.8, "Expression route changed disabled fallback intensity too early") + _expect(global_disabled.expression_intensity_source == "disabled", "Expression route did not mark global disabled source") + slot_disabled = row_expression.resolve_expression_route( + expression_enabled=True, + expression_intensity=0.5, + expression_intensity_source="input", + subject_type="woman", + applied_slot=disabled_slot, + ) + _expect(slot_disabled.expression_disabled is True, "Expression route did not honor single-slot disable") + _expect( + slot_disabled.expression_intensity_source == "character_slot:Woman A:disabled", + "Expression route lost single-slot disabled source", + ) + cast_disabled = row_expression.resolve_expression_route( + expression_enabled=True, + expression_intensity=0.5, + expression_intensity_source="input", + subject_type="configured_cast", + character_slots=[disabled_slot], + character_slot_map={"Woman A": disabled_slot}, + women_count=1, + men_count=0, + expression_phase="hardcore", + ) + _expect(cast_disabled.expression_disabled is True, "Expression route did not honor all-slot cast disable") + _expect(cast_disabled.expression_intensity is None, "Expression route did not clear all-slot disabled intensity") + _expect( + cast_disabled.expression_intensity_source == "character_slots:disabled", + "Expression route lost all-slot disabled source", + ) expression_text = "Woman A has steady focus; Man A has parted lips with saliva" context_role = "Woman A performs a handjob while Man A stands close" _expect(