From 28612f9d00b825168c4cb08e97189c017a574448 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Sat, 27 Jun 2026 10:49:58 +0200 Subject: [PATCH] Add typed pair route contracts --- docs/prompt-architecture-improvement-plan.md | 27 ++-- docs/prompt-pool-routing-map.md | 6 +- pair_camera.py | 112 +++++++++++--- pair_clothing.py | 66 +++++++-- pair_rows.py | 109 +++++++++++++- prompt_builder.py | 48 +++--- tools/prompt_smoke.py | 145 +++++++++++++++++++ 7 files changed, 439 insertions(+), 74 deletions(-) diff --git a/docs/prompt-architecture-improvement-plan.md b/docs/prompt-architecture-improvement-plan.md index cbfbc9c..40bf6e4 100644 --- a/docs/prompt-architecture-improvement-plan.md +++ b/docs/prompt-architecture-improvement-plan.md @@ -290,22 +290,25 @@ Already isolated: policy, plus hardcore detail-density directive text, live in `pair_options.py`; `prompt_builder.py` keeps public delegate wrappers for existing nodes and tests. -- soft/hard row creation lives in `pair_rows.py`, including softcore expression - override resolution, Woman A slot context application, soft outfit/pose - overrides, POV row fields, and hardcore row creation. +- soft/hard row creation lives in `pair_rows.py` behind `InstaPairRowsRoute`, + including softcore expression override resolution, Woman A slot context + application, soft outfit/pose overrides, POV row fields, hardcore row + creation, and legacy dict compatibility. - pair-level cast/display context lives in `pair_cast.py`, including descriptor prose, descriptor-entry assembly, shared descriptors, cast-label cleanup, same-cast softcore descriptor text, partner styling, platform and level labels, softcore cast presence text, and hard cast summary text. -- pair-level camera routing lives in `pair_camera.py`, including soft/hard - camera config selection, same-as-softcore mode, camera-detail override, - same-room hard scene continuity, camera-aware composition mutation, POV camera - suppression, and row/root camera metadata synchronization. -- pair-level clothing policy lives in `pair_clothing.py`, including clothing - sentence formatting, body-exposure scene cleanup, action-aware body-access - flags, conflicting outfit-piece cleanup, default visible-men clothing, - character-clothing override handling, hardcore clothing continuity, and final - root clothing-state assembly. +- pair-level camera routing lives in `pair_camera.py` behind + `InstaPairCameraRoute`, including soft/hard camera config selection, + same-as-softcore mode, camera-detail override, same-room hard scene + continuity, camera-aware composition mutation, POV camera suppression, + row/root camera metadata synchronization, and legacy dict compatibility. +- pair-level clothing policy lives in `pair_clothing.py` behind + `HardcorePairClothingRoute`, including clothing sentence formatting, + body-exposure scene cleanup, action-aware body-access flags, conflicting + outfit-piece cleanup, default visible-men clothing, character-clothing + override handling, hardcore clothing continuity, final root clothing-state + assembly, and legacy dict compatibility. - final pair output assembly lives in `pair_output.py`, including soft/hard prompt strings, trigger preservation, negatives, captions, and root metadata shape; the final cleanup step is delegated to `row_normalization.py`. diff --git a/docs/prompt-pool-routing-map.md b/docs/prompt-pool-routing-map.md index 3c906a5..cc5f828 100644 --- a/docs/prompt-pool-routing-map.md +++ b/docs/prompt-pool-routing-map.md @@ -96,10 +96,10 @@ Core helper ownership: | `row_prompt_axes.py` | Row scene/pose/expression/composition axis selection behind `PromptAxesRoute`, compatible-entry filtering, expression-disabled handling, per-character expression promotion, legacy dict compatibility, 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. | | `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. | -| `pair_rows.py` | Insta/OF soft/hard row creation, softcore expression override resolution, Woman A slot context application, soft outfit/pose overrides, and POV row fields. | +| `pair_rows.py` | Insta/OF soft/hard row creation behind `InstaPairRowsRoute`, softcore expression override resolution, Woman A slot context application, soft outfit/pose overrides, POV row fields, and legacy dict compatibility. | | `pair_cast.py` | Insta/OF descriptor prose, descriptor-entry assembly, shared descriptors, cast-label cleanup, same-cast softcore descriptor text, partner styling selection, cast-summary wording, platform/level labels, softcore cast presence text, and hard cast summary text. | -| `pair_camera.py` | Insta/OF soft/hard camera route resolution, same-as-softcore camera mode, camera-detail override, camera-aware composition mutation, POV camera suppression, and synchronized row/root camera metadata. | -| `pair_clothing.py` | Insta/OF clothing sentence formatting, body-exposure scene cleanup, hardcore clothing continuity, action-aware body-access flags, conflicting outfit-piece cleanup, configured/default visible-person clothing, and final root clothing-state assembly. | +| `pair_camera.py` | Insta/OF soft/hard camera route resolution behind `InstaPairCameraRoute`, same-as-softcore camera mode, camera-detail override, camera-aware composition mutation, POV camera suppression, synchronized row/root camera metadata, and legacy dict compatibility. | +| `pair_clothing.py` | Insta/OF clothing sentence formatting and hardcore clothing continuity behind `HardcorePairClothingRoute`, body-exposure scene cleanup, action-aware body-access flags, conflicting outfit-piece cleanup, configured/default visible-person clothing, final root clothing-state assembly, and legacy dict compatibility. | | `pair_output.py` | Insta/OF final pair prompts, trigger preservation, negative prompts, captions, and root pair metadata assembly. | | `hardcore_role_graphs.py` | Source role graph construction for hardcore configured-cast rows, including POV-aware interaction geometry, called through `row_role_graph.py` for row generation. | | `hardcore_role_fallback.py` | Solo, same-sex, mixed group fallback, and support-partner role graph wording for configured casts. | diff --git a/pair_camera.py b/pair_camera.py index bfdbef5..d87c061 100644 --- a/pair_camera.py +++ b/pair_camera.py @@ -1,5 +1,6 @@ from __future__ import annotations +from dataclasses import dataclass from typing import Any, Callable @@ -23,7 +24,43 @@ def camera_config_with_detail( return camera_config -def resolve_insta_pair_camera( +@dataclass(frozen=True) +class InstaPairCameraRoute: + soft_row: dict[str, Any] + hard_row: dict[str, Any] + hard_scene: str + hard_composition: str + soft_camera_config: dict[str, Any] + hard_camera_config: dict[str, Any] + soft_camera_directive: str + hard_camera_directive: str + soft_camera_scene_directive: str + hard_camera_scene_directive: str + soft_camera_scene_sentence: str + hard_camera_scene_sentence: str + soft_camera_sentence: str + hard_camera_sentence: str + + def as_dict(self) -> dict[str, Any]: + return { + "soft_row": self.soft_row, + "hard_row": self.hard_row, + "hard_scene": self.hard_scene, + "hard_composition": self.hard_composition, + "soft_camera_config": dict(self.soft_camera_config), + "hard_camera_config": dict(self.hard_camera_config), + "soft_camera_directive": self.soft_camera_directive, + "hard_camera_directive": self.hard_camera_directive, + "soft_camera_scene_directive": self.soft_camera_scene_directive, + "hard_camera_scene_directive": self.hard_camera_scene_directive, + "soft_camera_scene_sentence": self.soft_camera_scene_sentence, + "hard_camera_scene_sentence": self.hard_camera_scene_sentence, + "soft_camera_sentence": self.soft_camera_sentence, + "hard_camera_sentence": self.hard_camera_sentence, + } + + +def resolve_insta_pair_camera_result( *, soft_row: dict[str, Any], hard_row: dict[str, Any], @@ -41,7 +78,7 @@ def resolve_insta_pair_camera( contextual_composition_prompt: CompositionPrompt, composition_prompt: Callable[[Any], str], camera_scene_directive_for_context: CameraSceneDirective, -) -> dict[str, Any]: +) -> InstaPairCameraRoute: hard_camera_mode = str(options["hardcore_camera_mode"]) soft_camera_source = softcore_camera_config or camera_config hard_camera_source = hardcore_camera_config or camera_config @@ -108,19 +145,58 @@ def resolve_insta_pair_camera( hard_row["camera_directive"] = hard_camera_directive hard_row["camera_scene_directive"] = hard_camera_scene_directive - return { - "soft_row": soft_row, - "hard_row": hard_row, - "hard_scene": hard_scene, - "hard_composition": hard_composition, - "soft_camera_config": soft_camera_config_dict, - "hard_camera_config": hard_camera_config_dict, - "soft_camera_directive": soft_camera_directive, - "hard_camera_directive": hard_camera_directive, - "soft_camera_scene_directive": soft_camera_scene_directive, - "hard_camera_scene_directive": hard_camera_scene_directive, - "soft_camera_scene_sentence": f"{soft_camera_scene_directive} " if soft_camera_scene_directive else "", - "hard_camera_scene_sentence": f"{hard_camera_scene_directive} " if hard_camera_scene_directive else "", - "soft_camera_sentence": f"Camera control: {soft_camera_directive} " if soft_camera_directive else "", - "hard_camera_sentence": f"Camera control: {hard_camera_directive} " if hard_camera_directive else "", - } + return InstaPairCameraRoute( + soft_row=soft_row, + hard_row=hard_row, + hard_scene=hard_scene, + hard_composition=hard_composition, + soft_camera_config=soft_camera_config_dict, + hard_camera_config=hard_camera_config_dict, + soft_camera_directive=soft_camera_directive, + hard_camera_directive=hard_camera_directive, + soft_camera_scene_directive=soft_camera_scene_directive, + hard_camera_scene_directive=hard_camera_scene_directive, + soft_camera_scene_sentence=f"{soft_camera_scene_directive} " if soft_camera_scene_directive else "", + hard_camera_scene_sentence=f"{hard_camera_scene_directive} " if hard_camera_scene_directive else "", + soft_camera_sentence=f"Camera control: {soft_camera_directive} " if soft_camera_directive else "", + hard_camera_sentence=f"Camera control: {hard_camera_directive} " if hard_camera_directive else "", + ) + + +def resolve_insta_pair_camera( + *, + soft_row: dict[str, Any], + hard_row: dict[str, Any], + options: dict[str, Any], + camera_config: str | dict[str, Any] | None, + softcore_camera_config: str | dict[str, Any] | None, + hardcore_camera_config: str | dict[str, Any] | None, + hard_women_count: int, + hard_men_count: int, + pov_character_labels: list[str], + camera_detail_choices: list[str] | tuple[str, ...], + camera_config_with_mode: CameraConfigWithMode, + camera_directive: CameraDirective, + apply_contextual_composition: ApplyComposition, + contextual_composition_prompt: CompositionPrompt, + composition_prompt: Callable[[Any], str], + camera_scene_directive_for_context: CameraSceneDirective, +) -> dict[str, Any]: + return resolve_insta_pair_camera_result( + soft_row=soft_row, + hard_row=hard_row, + options=options, + camera_config=camera_config, + softcore_camera_config=softcore_camera_config, + hardcore_camera_config=hardcore_camera_config, + hard_women_count=hard_women_count, + hard_men_count=hard_men_count, + pov_character_labels=pov_character_labels, + camera_detail_choices=camera_detail_choices, + camera_config_with_mode=camera_config_with_mode, + camera_directive=camera_directive, + apply_contextual_composition=apply_contextual_composition, + contextual_composition_prompt=contextual_composition_prompt, + composition_prompt=composition_prompt, + camera_scene_directive_for_context=camera_scene_directive_for_context, + ).as_dict() diff --git a/pair_clothing.py b/pair_clothing.py index d971874..2541e49 100644 --- a/pair_clothing.py +++ b/pair_clothing.py @@ -1,5 +1,6 @@ from __future__ import annotations +from dataclasses import dataclass import re from typing import Any, Callable @@ -373,7 +374,27 @@ def default_man_hardcore_clothing_entries( return entries -def resolve_hardcore_pair_clothing( +@dataclass(frozen=True) +class HardcorePairClothingRoute: + access_flags: dict[str, bool] + woman_access: str + default_man_hardcore_clothing: list[str] + hardcore_clothing_state: str + hardcore_clothing_sentence: str + requires_body_exposure_scene: bool + + def as_dict(self) -> dict[str, Any]: + return { + "access_flags": dict(self.access_flags), + "woman_access": self.woman_access, + "default_man_hardcore_clothing": list(self.default_man_hardcore_clothing), + "hardcore_clothing_state": self.hardcore_clothing_state, + "hardcore_clothing_sentence": self.hardcore_clothing_sentence, + "requires_body_exposure_scene": self.requires_body_exposure_scene, + } + + +def resolve_hardcore_pair_clothing_result( *, hard_row: dict[str, Any], mode: str, @@ -384,7 +405,7 @@ def resolve_hardcore_pair_clothing( rng: Any, continuity_map: dict[str, str], choose: Callable[[Any, list[str]], str], -) -> dict[str, Any]: +) -> HardcorePairClothingRoute: access_flags = hardcore_row_access_flags(hard_row) woman_access = "lower" if access_flags["woman_lower"] else "upper" if access_flags["woman_upper"] else "" default_man_entries = default_man_hardcore_clothing_entries( @@ -412,14 +433,39 @@ def resolve_hardcore_pair_clothing( if str(part or "").strip() ] hard_clothing_state = "; ".join(hard_clothing_parts) - return { - "access_flags": access_flags, - "woman_access": woman_access, - "default_man_hardcore_clothing": default_man_entries, - "hardcore_clothing_state": hard_clothing_state, - "hardcore_clothing_sentence": f"{hard_clothing_state}. " if hard_clothing_state else "", - "requires_body_exposure_scene": ( + return HardcorePairClothingRoute( + access_flags=access_flags, + woman_access=woman_access, + default_man_hardcore_clothing=default_man_entries, + hardcore_clothing_state=hard_clothing_state, + hardcore_clothing_sentence=f"{hard_clothing_state}. " if hard_clothing_state else "", + requires_body_exposure_scene=( "body is fully exposed" in hard_clothing_state.lower() or "bare skin unobstructed" in hard_clothing_state.lower() ), - } + ) + + +def resolve_hardcore_pair_clothing( + *, + hard_row: dict[str, Any], + mode: str, + softcore_outfit: str, + character_hardcore_clothing_entries: list[str], + men_count: int, + pov_labels: list[str] | None, + rng: Any, + continuity_map: dict[str, str], + choose: Callable[[Any, list[str]], str], +) -> dict[str, Any]: + return resolve_hardcore_pair_clothing_result( + hard_row=hard_row, + mode=mode, + softcore_outfit=softcore_outfit, + character_hardcore_clothing_entries=character_hardcore_clothing_entries, + men_count=men_count, + pov_labels=pov_labels, + rng=rng, + continuity_map=continuity_map, + choose=choose, + ).as_dict() diff --git a/pair_rows.py b/pair_rows.py index f740c84..95e88e3 100644 --- a/pair_rows.py +++ b/pair_rows.py @@ -1,5 +1,6 @@ from __future__ import annotations +from dataclasses import dataclass from typing import Any, Callable try: @@ -12,7 +13,21 @@ BuildPrompt = Callable[..., dict[str, Any]] AxisRng = Callable[[dict[str, int], str, int, int], Any] -def build_insta_pair_rows( +@dataclass(frozen=True) +class InstaPairRowsRoute: + soft_row: dict[str, Any] + hard_row: dict[str, Any] + hard_content_rng: Any + + def as_dict(self) -> dict[str, Any]: + return { + "soft_row": self.soft_row, + "hard_row": self.hard_row, + "hard_content_rng": self.hard_content_rng, + } + + +def build_insta_pair_rows_result( *, row_number: int, start_index: int, @@ -52,7 +67,7 @@ def build_insta_pair_rows( softcore_item_prompt_label: Callable[[str], str], pov_prompt_directive: Callable[[list[str]], str], pov_composition_prompt: Callable[[Any, list[str]], str], -) -> dict[str, Any]: +) -> InstaPairRowsRoute: soft_content_rng = axis_rng(parsed_seed_config, "content", seed, row_number + 311) hard_content_rng = axis_rng(parsed_seed_config, "content", seed, row_number + 317) soft_person_rng = axis_rng(parsed_seed_config, "person", seed, row_number) @@ -186,8 +201,88 @@ def build_insta_pair_rows( hard_row["pov_character_labels"] = pov_character_labels hard_row["pov_prompt_directive"] = pov_prompt_directive(pov_character_labels) - return { - "soft_row": soft_row, - "hard_row": hard_row, - "hard_content_rng": hard_content_rng, - } + return InstaPairRowsRoute( + soft_row=soft_row, + hard_row=hard_row, + hard_content_rng=hard_content_rng, + ) + + +def build_insta_pair_rows( + *, + row_number: int, + start_index: int, + seed: int, + active_trigger: str, + parsed_seed_config: dict[str, int], + options: dict[str, Any], + ethnicity: str, + figure: str, + no_plus_women: bool, + no_black: bool, + character_profile: str | dict[str, Any] | None, + character_cast: str | dict[str, Any] | list[Any] | None, + character_slot_map: dict[str, dict[str, Any]], + pov_character_labels: list[str], + hard_women_count: int, + hard_men_count: int, + soft_category: str, + soft_subcategory: str, + softcore_level_key: str, + hardcore_random_subcategory: str, + hardcore_position_config: str | dict[str, Any] | None, + location_config: str | dict[str, Any] | None, + composition_config: str | dict[str, Any] | None, + build_prompt: BuildPrompt, + axis_rng: AxisRng, + cast_expression_intensity_override: Callable[ + [float, dict[str, dict[str, Any]], int, int, str], + tuple[float | None, str], + ], + context_from_character_slot: Callable[[Any, dict[str, Any], str, str, str, bool, bool], dict[str, Any]], + apply_character_context_to_row: Callable[[dict[str, Any], dict[str, Any]], dict[str, Any]], + disable_row_expression: Callable[[dict[str, Any], str], dict[str, Any]], + slot_softcore_outfit: Callable[[dict[str, Any] | None, Any], str], + softcore_outfit: Callable[[Any, str], str], + softcore_pose: Callable[[Any, str], str], + softcore_item_prompt_label: Callable[[str], str], + pov_prompt_directive: Callable[[list[str]], str], + pov_composition_prompt: Callable[[Any, list[str]], str], +) -> dict[str, Any]: + return build_insta_pair_rows_result( + row_number=row_number, + start_index=start_index, + seed=seed, + active_trigger=active_trigger, + parsed_seed_config=parsed_seed_config, + options=options, + ethnicity=ethnicity, + figure=figure, + no_plus_women=no_plus_women, + no_black=no_black, + character_profile=character_profile, + character_cast=character_cast, + character_slot_map=character_slot_map, + pov_character_labels=pov_character_labels, + hard_women_count=hard_women_count, + hard_men_count=hard_men_count, + soft_category=soft_category, + soft_subcategory=soft_subcategory, + softcore_level_key=softcore_level_key, + hardcore_random_subcategory=hardcore_random_subcategory, + hardcore_position_config=hardcore_position_config, + location_config=location_config, + composition_config=composition_config, + build_prompt=build_prompt, + axis_rng=axis_rng, + cast_expression_intensity_override=cast_expression_intensity_override, + context_from_character_slot=context_from_character_slot, + apply_character_context_to_row=apply_character_context_to_row, + disable_row_expression=disable_row_expression, + slot_softcore_outfit=slot_softcore_outfit, + softcore_outfit=softcore_outfit, + softcore_pose=softcore_pose, + softcore_item_prompt_label=softcore_item_prompt_label, + pov_prompt_directive=pov_prompt_directive, + pov_composition_prompt=pov_composition_prompt, + ).as_dict() diff --git a/prompt_builder.py b/prompt_builder.py index 78bb74b..7e7e2aa 100644 --- a/prompt_builder.py +++ b/prompt_builder.py @@ -2812,7 +2812,7 @@ def build_insta_of_pair( pov_character_labels = _pov_character_labels(character_slot_map, hard_men_count) softcore_level_key = str(options["softcore_level"]) soft_category, soft_subcategory = _insta_of_softcore_category(softcore_level_key) - row_route = pair_rows.build_insta_pair_rows( + row_route = pair_rows.build_insta_pair_rows_result( row_number=row_number, start_index=start_index, seed=seed, @@ -2849,9 +2849,9 @@ def build_insta_of_pair( pov_prompt_directive=_pov_prompt_directive, pov_composition_prompt=_pov_composition_prompt, ) - soft_row = row_route["soft_row"] - hard_row = row_route["hard_row"] - hard_content_rng = row_route["hard_content_rng"] + soft_row = row_route.soft_row + hard_row = row_route.hard_row + hard_content_rng = row_route.hard_content_rng cast_context = pair_cast.resolve_insta_pair_cast_context( soft_row=soft_row, @@ -2885,7 +2885,7 @@ def build_insta_of_pair( platform_style = cast_context["platform_style"] soft_level = cast_context["soft_level"] hard_level = cast_context["hard_level"] - camera_route = pair_camera.resolve_insta_pair_camera( + camera_route = pair_camera.resolve_insta_pair_camera_result( soft_row=soft_row, hard_row=hard_row, options=options, @@ -2903,20 +2903,20 @@ def build_insta_of_pair( composition_prompt=_composition_prompt, camera_scene_directive_for_context=_camera_scene_directive_for_context, ) - soft_row = camera_route["soft_row"] - hard_row = camera_route["hard_row"] - hard_scene = camera_route["hard_scene"] - hard_composition = camera_route["hard_composition"] - soft_camera_config = camera_route["soft_camera_config"] - hard_camera_config = camera_route["hard_camera_config"] - soft_camera_directive = camera_route["soft_camera_directive"] - hard_camera_directive = camera_route["hard_camera_directive"] - soft_camera_scene_directive = camera_route["soft_camera_scene_directive"] - hard_camera_scene_directive = camera_route["hard_camera_scene_directive"] - soft_camera_scene_sentence = camera_route["soft_camera_scene_sentence"] - hard_camera_scene_sentence = camera_route["hard_camera_scene_sentence"] - soft_camera_sentence = camera_route["soft_camera_sentence"] - hard_camera_sentence = camera_route["hard_camera_sentence"] + soft_row = camera_route.soft_row + hard_row = camera_route.hard_row + hard_scene = camera_route.hard_scene + hard_composition = camera_route.hard_composition + soft_camera_config = camera_route.soft_camera_config + hard_camera_config = camera_route.hard_camera_config + soft_camera_directive = camera_route.soft_camera_directive + hard_camera_directive = camera_route.hard_camera_directive + soft_camera_scene_directive = camera_route.soft_camera_scene_directive + hard_camera_scene_directive = camera_route.hard_camera_scene_directive + soft_camera_scene_sentence = camera_route.soft_camera_scene_sentence + hard_camera_scene_sentence = camera_route.hard_camera_scene_sentence + soft_camera_sentence = camera_route.soft_camera_sentence + hard_camera_sentence = camera_route.hard_camera_sentence soft_cast = cast_context["soft_cast"] soft_cast_presence = cast_context["soft_cast_presence"] soft_cast_styling_sentence = cast_context["soft_cast_styling_sentence"] @@ -2929,7 +2929,7 @@ def build_insta_of_pair( hard_content_rng, _slot_hardcore_clothing, ) - clothing_route = pair_clothing.resolve_hardcore_pair_clothing( + clothing_route = pair_clothing.resolve_hardcore_pair_clothing_result( hard_row=hard_row, mode=options["hardcore_clothing_continuity"], softcore_outfit=soft_row["item"], @@ -2940,10 +2940,10 @@ def build_insta_of_pair( continuity_map=INSTA_OF_HARDCORE_CLOTHING_CONTINUITY, choose=g.choose, ) - default_man_hardcore_clothing_entries = clothing_route["default_man_hardcore_clothing"] - hard_clothing_state = clothing_route["hardcore_clothing_state"] - hard_clothing_sentence = clothing_route["hardcore_clothing_sentence"] - if clothing_route["requires_body_exposure_scene"]: + default_man_hardcore_clothing_entries = clothing_route.default_man_hardcore_clothing + hard_clothing_state = clothing_route.hardcore_clothing_state + hard_clothing_sentence = clothing_route.hardcore_clothing_sentence + if clothing_route.requires_body_exposure_scene: hard_scene = pair_clothing.body_exposure_scene_text(hard_scene) hard_row["source_scene_text"] = hard_row.get("source_scene_text") or hard_row.get("scene_text", "") hard_row["scene_text"] = hard_scene diff --git a/tools/prompt_smoke.py b/tools/prompt_smoke.py index 4caf472..5424dc7 100644 --- a/tools/prompt_smoke.py +++ b/tools/prompt_smoke.py @@ -45,8 +45,10 @@ import krea_cast # noqa: E402 import krea_formatter # noqa: E402 import location_config # noqa: E402 import loop_nodes # noqa: E402 +import pair_camera # noqa: E402 import pair_cast # noqa: E402 import pair_clothing # noqa: E402 +import pair_rows # noqa: E402 import prompt_builder as pb # noqa: E402 import pov_policy # noqa: E402 import row_normalization # noqa: E402 @@ -2922,6 +2924,148 @@ def smoke_pair_options_policy() -> None: _expect(pb._insta_of_softcore_category("social_tease") == ("Casual clothes", "Casual clothes / Smart casual"), "softcore category mapping changed") +def smoke_pair_route_policy() -> None: + def _fake_build_prompt(**kwargs: Any) -> dict[str, Any]: + is_hard = kwargs.get("category") == "Hardcore sexual poses" + return { + "category": kwargs.get("category"), + "subcategory": kwargs.get("subcategory"), + "scene_text": "shared test room", + "source_scene_text": "", + "composition": "centered test composition", + "source_composition": "", + "expression": "steady look", + "role_graph": "test role graph", + "item": "test item", + "positive_suffix": "test positive suffix", + "negative_prompt": "test negative", + "prompt": "test hard prompt" if is_hard else "test soft prompt", + "caption": "test hard caption" if is_hard else "test soft caption", + } + + pair_options_data = { + "softcore_cast": "solo", + "softcore_expression_enabled": True, + "softcore_expression_intensity": 0.45, + "hardcore_expression_enabled": True, + "hardcore_expression_intensity": 0.85, + "hardcore_detail_density": "balanced", + } + pair_rows_kwargs: dict[str, Any] = { + "row_number": 1, + "start_index": 1, + "seed": 10, + "active_trigger": Trigger, + "parsed_seed_config": {}, + "options": pair_options_data, + "ethnicity": "any", + "figure": "random", + "no_plus_women": False, + "no_black": False, + "character_profile": "", + "character_cast": "", + "character_slot_map": {}, + "pov_character_labels": [], + "hard_women_count": 1, + "hard_men_count": 1, + "soft_category": "Casual clothes", + "soft_subcategory": "Casual clothes / Smart casual", + "softcore_level_key": "social_tease", + "hardcore_random_subcategory": pb.RANDOM_SUBCATEGORY, + "hardcore_position_config": "", + "location_config": "", + "composition_config": "", + "build_prompt": _fake_build_prompt, + "axis_rng": lambda _config, _axis, seed_value, row_value: random.Random(seed_value + row_value), + "cast_expression_intensity_override": lambda value, _slots, _women, _men, _phase: (value, "input"), + "context_from_character_slot": lambda *_args, **_kwargs: {}, + "apply_character_context_to_row": lambda row, context: {**row, **context}, + "disable_row_expression": lambda row, source: {**row, "expression_disabled": True, "expression_intensity_source": source}, + "slot_softcore_outfit": lambda _slot, _rng: "", + "softcore_outfit": lambda _rng, _level: "test soft outfit", + "softcore_pose": lambda _rng, _level: "test soft pose", + "softcore_item_prompt_label": lambda _level: "Softcore test outfit", + "pov_prompt_directive": lambda labels: "POV directive" if labels else "", + "pov_composition_prompt": lambda composition, labels: f"{composition} for {','.join(labels)}" if labels else str(composition), + } + rows_route = pair_rows.build_insta_pair_rows_result(**pair_rows_kwargs) + rows_legacy = pair_rows.build_insta_pair_rows(**pair_rows_kwargs) + _expect(rows_route.soft_row == rows_legacy["soft_row"], "Typed pair row route should match legacy soft row") + _expect(rows_route.hard_row == rows_legacy["hard_row"], "Typed pair row route should match legacy hard row") + _expect( + rows_route.hard_content_rng.getstate() == rows_legacy["hard_content_rng"].getstate(), + "Typed pair row route should match legacy hard content RNG state", + ) + _expect(rows_route.soft_row["item"] == "test soft outfit", "Typed pair row route lost soft outfit override") + _expect(rows_route.hard_row["hardcore_detail_density"] == "balanced", "Typed pair row route lost hard density") + + camera_options = { + "hardcore_camera_mode": "same_as_softcore", + "softcore_camera_mode": "standard", + "camera_detail": "compact", + "softcore_cast": "same_as_hardcore", + "continuity": "same_creator_same_room", + } + + def _camera_config_with_mode(source: Any, mode: str) -> dict[str, Any]: + parsed = dict(source or {}) + parsed["camera_mode"] = mode + return parsed + + def _camera_directive(config: dict[str, Any]) -> tuple[str, dict[str, Any]]: + return f"{config.get('camera_mode')} camera", config + + def _camera_rows() -> tuple[dict[str, Any], dict[str, Any]]: + return ( + {"scene_text": "soft room", "composition": "soft composition"}, + {"scene_text": "hard room", "composition": "hard composition"}, + ) + + camera_common = { + "options": camera_options, + "camera_config": {"base": "camera"}, + "softcore_camera_config": None, + "hardcore_camera_config": None, + "hard_women_count": 1, + "hard_men_count": 1, + "pov_character_labels": [], + "camera_detail_choices": ("compact",), + "camera_config_with_mode": _camera_config_with_mode, + "camera_directive": _camera_directive, + "apply_contextual_composition": lambda row, subject_kind: {**row, "subject_kind": subject_kind}, + "contextual_composition_prompt": lambda scene, composition, subject_kind: f"{subject_kind}: {scene}: {composition}", + "composition_prompt": lambda composition: f"Framed as {composition}", + "camera_scene_directive_for_context": lambda _scene, _composition, config, _pov, subject: (f"{subject} scene directive", config), + } + soft_row, hard_row = _camera_rows() + camera_route = pair_camera.resolve_insta_pair_camera_result(soft_row=soft_row, hard_row=hard_row, **camera_common) + soft_row, hard_row = _camera_rows() + camera_legacy = pair_camera.resolve_insta_pair_camera(soft_row=soft_row, hard_row=hard_row, **camera_common) + _expect(camera_route.as_dict() == camera_legacy, "Typed pair camera route should match legacy dict route") + _expect(camera_route.hard_scene == "soft room", "Typed pair camera route lost same-room continuity") + _expect(camera_route.hard_camera_sentence.startswith("Camera control:"), "Typed pair camera route lost hard camera sentence") + + clothing_common = { + "hard_row": { + "role_graph": "the man thrusts his penis into the woman", + "item": "penetrative test item", + }, + "mode": "explicit_nude", + "softcore_outfit": "test lingerie", + "character_hardcore_clothing_entries": [], + "men_count": 0, + "pov_labels": [], + "rng": random.Random(1), + "continuity_map": pb.INSTA_OF_HARDCORE_CLOTHING_CONTINUITY, + "choose": lambda _rng, pool: pool[0], + } + clothing_route = pair_clothing.resolve_hardcore_pair_clothing_result(**clothing_common) + clothing_legacy = pair_clothing.resolve_hardcore_pair_clothing(**clothing_common) + _expect(clothing_route.as_dict() == clothing_legacy, "Typed pair clothing route should match legacy dict route") + _expect(clothing_route.woman_access == "lower", "Typed pair clothing route lost lower-access detection") + _expect(clothing_route.requires_body_exposure_scene is True, "Typed pair clothing route lost exposure-scene flag") + + def _expect_pair(pair: dict[str, Any], name: str) -> None: _expect(pair.get("mode") == "Insta/OF", f"{name}.mode should be Insta/OF") _expect_row_base(pair.get("softcore_row") or {}, f"{name}.softcore_row") @@ -4823,6 +4967,7 @@ SMOKE_CASES: list[tuple[str, Callable[[], None]]] = [ ("hardcore_category_routes", smoke_hardcore_category_routes), ("krea_close_foreplay_route", smoke_krea_close_foreplay_route), ("pair_options_policy", smoke_pair_options_policy), + ("pair_route_policy", smoke_pair_route_policy), ("insta_pair_same_cast", smoke_insta_pair), ("krea_pair_clothing_state", smoke_krea_pair_clothing_state), ("insta_pair_pov_man", smoke_insta_pair_pov),