diff --git a/docs/prompt-architecture-improvement-plan.md b/docs/prompt-architecture-improvement-plan.md index 7032a18..9dbdb4a 100644 --- a/docs/prompt-architecture-improvement-plan.md +++ b/docs/prompt-architecture-improvement-plan.md @@ -129,6 +129,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. +- 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. - category/cast route preset schemas, config JSON builders, choice lists, and parsers live in `category_cast_config.py`; `prompt_builder.py` keeps public delegate wrappers for existing nodes and tests. diff --git a/docs/prompt-pool-routing-map.md b/docs/prompt-pool-routing-map.md index 5ce6636..a12c0bd 100644 --- a/docs/prompt-pool-routing-map.md +++ b/docs/prompt-pool-routing-map.md @@ -70,6 +70,7 @@ Core helper ownership: | `category_library.py` | JSON category loading, subcategory normalization, named scene/expression/composition pool loading, cast compatibility filtering, exact subcategory lookup, and inheritance-based pool merging. | | `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_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. | | `camera_config.py` | Camera option schema, direct/orbit/Qwen camera JSON builders, camera config parsing, plain camera directive text, and camera caption labels. | @@ -198,7 +199,7 @@ There are two category systems. | Source | Files/functions | Notes | | --- | --- | --- | -| Built-in legacy generator | `generate_prompt_batches.py`, `_build_direct_builtin_row`, `_build_auto_weighted_row` | Handles legacy `woman`, `man`, `couple`, `group_or_layout`, `auto_weighted`, and `auto_full`. | +| Built-in legacy generator | `generate_prompt_batches.py`, `row_generation.py` | Handles legacy `woman`, `man`, `couple`, `group_or_layout`, `auto_weighted`, and `auto_full`. | | JSON category library | `categories/*.json`, `category_library.load_category_library`, `_build_custom_row` | Handles expandable categories such as casual clothes, erotic clothes, and hardcore sexual poses. | JSON categories are the scalable system. Add new main categories or subcategories diff --git a/prompt_builder.py b/prompt_builder.py index b23fa6d..1525b1a 100644 --- a/prompt_builder.py +++ b/prompt_builder.py @@ -40,6 +40,7 @@ try: 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_generation as row_generation_policy from . import row_item as row_item_policy from . import row_location as row_location_policy from . import row_pools as row_pool_policy @@ -84,6 +85,7 @@ except ImportError: # Allows local smoke tests with `python -c`. import row_normalization as row_policy import row_camera as row_camera_policy import row_expression as row_expression_policy + import row_generation as row_generation_policy import row_item as row_item_policy import row_location as row_location_policy import row_pools as row_pool_policy @@ -771,21 +773,11 @@ def _apply_hardcore_position_config_to_subcategory( def _ratio_or_none(value: float) -> float | None: - try: - ratio = float(value) - except (TypeError, ValueError): - return None - if ratio < 0: - return None - return max(0.0, min(1.0, ratio)) + return row_generation_policy.ratio_or_none(value) 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)) + return row_generation_policy.clamped_float(value, default, min_value, max_value) def build_seed_config_json( @@ -1251,35 +1243,19 @@ def _row_seed(seed: int, row_number: int, salt: int = 0) -> int: def _pick_clothing_mode(rng: random.Random, clothing: str, minimal_ratio: float | None) -> str: - if clothing == "random": - return "minimal" if rng.random() < 0.5 else "full" - if minimal_ratio is None: - return clothing - return "minimal" if rng.random() < minimal_ratio else "full" + return row_generation_policy.pick_clothing_mode(rng, clothing, minimal_ratio) def _pick_pose_mode(rng: random.Random, poses: str, standard_ratio: float | None) -> str: - if poses == "random": - return "standard" if rng.random() < 0.5 else "evocative" - if standard_ratio is None: - return poses - return "standard" if rng.random() < standard_ratio else "evocative" + return row_generation_policy.pick_pose_mode(rng, poses, standard_ratio) def _pick_figure_bias(rng: random.Random, figure: str) -> str: - if figure in ("curvy", "balanced", "bombshell"): - return figure - return g.choose(rng, ["curvy", "balanced", "bombshell"]) + return row_generation_policy.pick_figure_bias(rng, figure) def _pick_expression_intensity(rng: random.Random, expression_intensity: Any) -> tuple[float, str]: - try: - value = float(expression_intensity) - except (TypeError, ValueError): - return 0.5, "default" - if value < 0: - return round(rng.random(), 2), "random" - return _clamped_float(value, 0.5), "input" + return row_generation_policy.pick_expression_intensity(rng, expression_intensity) def _build_auto_weighted_row( @@ -1296,9 +1272,8 @@ def _build_auto_weighted_row( standard_pose_ratio: float | None, seed: int, ) -> dict[str, Any]: - batch_number = max(1, ((row_number - 1) // g.BATCH_SIZE) + 1) - rows = g.build_rows( - batch_number * g.BATCH_SIZE, + return row_generation_policy.build_auto_weighted_row( + row_number, start_index, clothing, ethnicity, @@ -1310,13 +1285,7 @@ def _build_auto_weighted_row( minimal_clothing_ratio, standard_pose_ratio, seed, - g.EXPRESSION_SEED + seed, ) - row = rows[row_number - 1] - row["main_category"] = "auto_weighted" - row["subcategory"] = row.get("primary_subject", "auto") - row["source"] = "built_in_generator" - return row def _build_direct_builtin_row( @@ -1334,58 +1303,25 @@ def _build_direct_builtin_row( standard_pose_ratio: float | None, seed: int, ) -> dict[str, Any]: - rng = random.Random(_row_seed(seed, row_number)) - expr_deck = g.ExpressionDeck(g.EXPRESSIONS, random.Random(_row_seed(g.EXPRESSION_SEED + seed, row_number))) - batch = max(1, ((row_number - 1) // g.BATCH_SIZE) + 1) - index = start_index + row_number - 1 - row_clothing = _pick_clothing_mode(rng, clothing, minimal_clothing_ratio) - row_poses = _pick_pose_mode(rng, poses, standard_pose_ratio) - - if category == "woman": - row = g.make_single( - index, - batch, - rng, - "woman", - expr_deck, - row_clothing, - ethnicity, - row_poses, - backside_bias, - figure, - no_plus_women, - no_black, - ) - elif category == "man": - row = g.make_single(index, batch, rng, "man", expr_deck, row_clothing, ethnicity, row_poses, backside_bias, figure) - elif category == "couple": - row = g.make_couple(index, batch, rng, expr_deck, row_clothing, ethnicity, no_plus_women) - elif category == "group_or_layout": - row = g.make_group_or_layout(index, batch, rng, expr_deck, row_clothing, ethnicity, no_plus_women) - else: - raise ValueError(f"Unknown built-in category: {category}") - - row["main_category"] = category - row["subcategory"] = row.get("pose_mode", category) - row["source"] = "built_in_generator" - return row + return row_generation_policy.build_direct_builtin_row( + category, + row_number, + start_index, + clothing, + ethnicity, + poses, + backside_bias, + figure, + no_plus_women, + no_black, + minimal_clothing_ratio, + standard_pose_ratio, + seed, + ) def _auto_full_choice(seed_config: dict[str, int], seed: int, row_number: int) -> str: - categories = load_category_library() - if not categories: - return "auto_weighted" - category_rng = _axis_rng(seed_config, "category", seed, row_number) - choices: list[dict[str, Any]] = [{"category": "auto_weighted", "weight": 1.0}] - choices.extend( - { - "category": category["name"], - "weight": category.get("weight", 1.0), - } - for category in categories - ) - choice = _weighted_choice(category_rng, choices) - return str(choice.get("category") or "auto_weighted") + return row_generation_policy.auto_full_choice(seed_config, seed, row_number) def _body_phrase(body: Any, figure_note: Any = "") -> str: diff --git a/row_generation.py b/row_generation.py new file mode 100644 index 0000000..eaefa11 --- /dev/null +++ b/row_generation.py @@ -0,0 +1,174 @@ +from __future__ import annotations + +import random +from typing import Any + +try: + from . import category_library as category_policy + from . import generate_prompt_batches as g + from . import row_item as row_item_policy + from . import seed_config as seed_policy +except ImportError: # Allows local smoke tests with top-level imports. + import category_library as category_policy + import generate_prompt_batches as g + import row_item as row_item_policy + import seed_config as seed_policy + + +def ratio_or_none(value: float) -> float | None: + try: + ratio = float(value) + except (TypeError, ValueError): + return None + if ratio < 0: + return None + return max(0.0, min(1.0, ratio)) + + +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 pick_clothing_mode(rng: random.Random, clothing: str, minimal_ratio: float | None) -> str: + if clothing == "random": + return "minimal" if rng.random() < 0.5 else "full" + if minimal_ratio is None: + return clothing + return "minimal" if rng.random() < minimal_ratio else "full" + + +def pick_pose_mode(rng: random.Random, poses: str, standard_ratio: float | None) -> str: + if poses == "random": + return "standard" if rng.random() < 0.5 else "evocative" + if standard_ratio is None: + return poses + return "standard" if rng.random() < standard_ratio else "evocative" + + +def pick_figure_bias(rng: random.Random, figure: str) -> str: + if figure in ("curvy", "balanced", "bombshell"): + return figure + return g.choose(rng, ["curvy", "balanced", "bombshell"]) + + +def pick_expression_intensity(rng: random.Random, expression_intensity: Any) -> tuple[float, str]: + try: + value = float(expression_intensity) + except (TypeError, ValueError): + return 0.5, "default" + if value < 0: + return round(rng.random(), 2), "random" + return clamped_float(value, 0.5), "input" + + +def build_auto_weighted_row( + row_number: int, + start_index: int, + clothing: str, + ethnicity: str, + poses: str, + backside_bias: float, + figure: str, + no_plus_women: bool, + no_black: bool, + minimal_clothing_ratio: float | None, + standard_pose_ratio: float | None, + seed: int, +) -> dict[str, Any]: + batch_number = max(1, ((row_number - 1) // g.BATCH_SIZE) + 1) + rows = g.build_rows( + batch_number * g.BATCH_SIZE, + start_index, + clothing, + ethnicity, + poses, + backside_bias, + figure, + no_plus_women, + no_black, + minimal_clothing_ratio, + standard_pose_ratio, + seed, + g.EXPRESSION_SEED + seed, + ) + row = rows[row_number - 1] + row["main_category"] = "auto_weighted" + row["subcategory"] = row.get("primary_subject", "auto") + row["source"] = "built_in_generator" + return row + + +def build_direct_builtin_row( + category: str, + row_number: int, + start_index: int, + clothing: str, + ethnicity: str, + poses: str, + backside_bias: float, + figure: str, + no_plus_women: bool, + no_black: bool, + minimal_clothing_ratio: float | None, + standard_pose_ratio: float | None, + seed: int, +) -> dict[str, Any]: + rng = random.Random(seed_policy.row_seed(seed, row_number)) + expr_deck = g.ExpressionDeck( + g.EXPRESSIONS, + random.Random(seed_policy.row_seed(g.EXPRESSION_SEED + seed, row_number)), + ) + batch = max(1, ((row_number - 1) // g.BATCH_SIZE) + 1) + index = start_index + row_number - 1 + row_clothing = pick_clothing_mode(rng, clothing, minimal_clothing_ratio) + row_poses = pick_pose_mode(rng, poses, standard_pose_ratio) + + if category == "woman": + row = g.make_single( + index, + batch, + rng, + "woman", + expr_deck, + row_clothing, + ethnicity, + row_poses, + backside_bias, + figure, + no_plus_women, + no_black, + ) + elif category == "man": + row = g.make_single(index, batch, rng, "man", expr_deck, row_clothing, ethnicity, row_poses, backside_bias, figure) + elif category == "couple": + row = g.make_couple(index, batch, rng, expr_deck, row_clothing, ethnicity, no_plus_women) + elif category == "group_or_layout": + row = g.make_group_or_layout(index, batch, rng, expr_deck, row_clothing, ethnicity, no_plus_women) + else: + raise ValueError(f"Unknown built-in category: {category}") + + row["main_category"] = category + row["subcategory"] = row.get("pose_mode", category) + row["source"] = "built_in_generator" + return row + + +def auto_full_choice(seed_config: dict[str, int], seed: int, row_number: int) -> str: + categories = category_policy.load_category_library() + if not categories: + return "auto_weighted" + category_rng = seed_policy.axis_rng(seed_config, "category", seed, row_number) + choices: list[dict[str, Any]] = [{"category": "auto_weighted", "weight": 1.0}] + choices.extend( + { + "category": category["name"], + "weight": category.get("weight", 1.0), + } + for category in categories + ) + choice = row_item_policy.weighted_choice(category_rng, choices) + return str(choice.get("category") or "auto_weighted") diff --git a/tools/prompt_smoke.py b/tools/prompt_smoke.py index eb07aa7..d43d871 100644 --- a/tools/prompt_smoke.py +++ b/tools/prompt_smoke.py @@ -52,6 +52,7 @@ import row_normalization # noqa: E402 import route_metadata # noqa: E402 import row_camera # noqa: E402 import row_expression # noqa: E402 +import row_generation # noqa: E402 import row_item # noqa: E402 import row_location # noqa: E402 import row_pools # noqa: E402 @@ -810,6 +811,77 @@ def smoke_row_item_policy() -> None: _expect(metadata.get("action_family") == "oral", "Row item compose lost template metadata") +def smoke_row_generation_policy() -> None: + _expect(pb._ratio_or_none(-1) is None, "Prompt builder ratio helper should treat negative as unset") + _expect(pb._ratio_or_none(1.5) == row_generation.ratio_or_none(1.5) == 1.0, "Row generation ratio clamp changed") + _expect(pb._clamped_float("bad", 0.4) == row_generation.clamped_float("bad", 0.4) == 0.4, "Row generation float default changed") + + _expect( + pb._pick_clothing_mode(random.Random(1), "random", None) + == row_generation.pick_clothing_mode(random.Random(1), "random", None), + "Prompt builder clothing mode picker should delegate to row_generation", + ) + _expect( + row_generation.pick_pose_mode(random.Random(2), "evocative", 1.0) == "standard", + "Row generation standard pose ratio override changed", + ) + _expect( + pb._pick_figure_bias(random.Random(3), "random") == row_generation.pick_figure_bias(random.Random(3), "random"), + "Prompt builder figure picker should delegate to row_generation", + ) + _expect( + row_generation.pick_expression_intensity(random.Random(4), -1) == (0.24, "random"), + "Row generation random expression intensity changed", + ) + _expect( + pb._pick_expression_intensity(random.Random(4), 2.0) == row_generation.pick_expression_intensity(random.Random(4), 2.0) == (1.0, "input"), + "Prompt builder expression intensity picker should delegate to row_generation", + ) + + direct_args = dict( + category="woman", + row_number=3, + start_index=41, + clothing="full", + ethnicity="any", + poses="standard", + backside_bias=0.25, + figure="curvy", + no_plus_women=False, + no_black=False, + minimal_clothing_ratio=None, + standard_pose_ratio=None, + seed=5050, + ) + _expect( + pb._build_direct_builtin_row(**direct_args) == row_generation.build_direct_builtin_row(**direct_args), + "Prompt builder direct built-in row should delegate to row_generation", + ) + auto_args = dict( + row_number=2, + start_index=41, + clothing="minimal", + ethnicity="any", + poses="evocative", + backside_bias=0.0, + figure="balanced", + no_plus_women=False, + no_black=False, + minimal_clothing_ratio=None, + standard_pose_ratio=None, + seed=6060, + ) + auto_row = row_generation.build_auto_weighted_row(**auto_args) + _expect(pb._build_auto_weighted_row(**auto_args) == auto_row, "Prompt builder auto-weighted row should delegate to row_generation") + _expect(auto_row.get("source") == "built_in_generator", "Row generation auto-weighted row lost source metadata") + + seed_cfg = seed_config.parse_seed_config({"category_seed": 123}) + _expect( + pb._auto_full_choice(seed_cfg, 7070, 1) == row_generation.auto_full_choice(seed_cfg, 7070, 1), + "Prompt builder auto-full choice should delegate to row_generation", + ) + + 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") @@ -4010,6 +4082,7 @@ SMOKE_CASES: list[tuple[str, Callable[[], None]]] = [ ("row_location_policy", smoke_row_location_policy), ("row_expression_policy", smoke_row_expression_policy), ("row_item_policy", smoke_row_item_policy), + ("row_generation_policy", smoke_row_generation_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),