From 84c369c190e7df92d1f817ef06aa8af4206133ba Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Sat, 27 Jun 2026 12:17:05 +0200 Subject: [PATCH] Extract builder prompt route --- builder_prompt_route.py | 219 +++++++++++++++++++ docs/prompt-architecture-improvement-plan.md | 4 + docs/prompt-pool-routing-map.md | 5 +- prompt_builder.py | 181 ++++++--------- tools/prompt_smoke.py | 71 ++++++ 5 files changed, 362 insertions(+), 118 deletions(-) create mode 100644 builder_prompt_route.py diff --git a/builder_prompt_route.py b/builder_prompt_route.py new file mode 100644 index 0000000..cd76e00 --- /dev/null +++ b/builder_prompt_route.py @@ -0,0 +1,219 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Callable + + +@dataclass(frozen=True) +class PromptBuildRequest: + category: str + subcategory: str + row_number: int + start_index: int + seed: int + clothing: str + ethnicity: str + poses: str + backside_bias: float + figure: str + no_plus_women: bool + no_black: bool + minimal_clothing_ratio: float + standard_pose_ratio: float + trigger: str + prepend_trigger_to_prompt: bool + extra_positive: str + extra_negative: str + seed_config: str | dict[str, Any] | None = None + women_count: int = 1 + men_count: int = 1 + camera_config: str | dict[str, Any] | None = None + expression_intensity: float = 0.5 + character_profile: str | dict[str, Any] | None = None + character_cast: str | dict[str, Any] | list[Any] | None = None + expression_enabled: bool = True + expression_phase: str = "" + hardcore_position_config: str | dict[str, Any] | None = None + location_config: str | dict[str, Any] | None = None + composition_config: str | dict[str, Any] | None = None + + +@dataclass(frozen=True) +class PromptBuildRoute: + row: dict[str, Any] + category: str + subcategory: str + branch: str + parsed_seed_config: dict[str, Any] + expression_intensity: float + expression_intensity_source: str + + +@dataclass(frozen=True) +class PromptBuildDependencies: + default_trigger: str + default_negative: str + random_subcategory: str + apply_pool_extensions: Callable[[], Any] + normalize_ethnicity_filter: Callable[[Any, str], str] + is_false: Callable[[Any], bool] + ratio_or_none: Callable[[Any], float | None] + parse_seed_config: Callable[[str | dict[str, Any] | None], dict[str, Any]] + parse_location_config: Callable[[str | dict[str, Any] | None], dict[str, Any]] + parse_composition_config: Callable[[str | dict[str, Any] | None], dict[str, Any]] + axis_rng: Callable[[dict[str, Any], str, int, int], Any] + pick_clothing_mode: Callable[[Any, str, float | None], str] + pick_pose_mode: Callable[[Any, str, float | None], str] + pick_figure_bias: Callable[[Any, str], str] + pick_expression_intensity: Callable[[Any, Any], tuple[float, str]] + auto_full_choice: Callable[[dict[str, Any], int, int], str] + build_auto_weighted_row: Callable[..., dict[str, Any]] + build_direct_builtin_row: Callable[..., dict[str, Any]] + build_custom_row: Callable[..., dict[str, Any]] + apply_location_config_to_legacy_row: Callable[..., dict[str, Any]] + apply_composition_config_to_legacy_row: Callable[..., dict[str, Any]] + disable_row_expression: Callable[[dict[str, Any], str], dict[str, Any]] + apply_camera_config: Callable[[dict[str, Any], str | dict[str, Any] | None], dict[str, Any]] + normalize_prompt_row: Callable[..., dict[str, Any]] + + +def build_prompt_result(request: PromptBuildRequest, deps: PromptBuildDependencies) -> PromptBuildRoute: + deps.apply_pool_extensions() + row_number = max(1, int(request.row_number)) + start_index = max(1, int(request.start_index)) + seed = int(request.seed) + category = request.category + subcategory = request.subcategory + ethnicity = deps.normalize_ethnicity_filter(request.ethnicity, "any") + expression_enabled = not deps.is_false(request.expression_enabled) + minimal_ratio = deps.ratio_or_none(request.minimal_clothing_ratio) + pose_ratio = deps.ratio_or_none(request.standard_pose_ratio) + parsed_seed_config = deps.parse_seed_config(request.seed_config) + parsed_location_config = deps.parse_location_config(request.location_config) + parsed_composition_config = deps.parse_composition_config(request.composition_config) + content_rng = deps.axis_rng(parsed_seed_config, "content", seed, row_number) + pose_axis_rng = deps.axis_rng(parsed_seed_config, "pose", seed, row_number) + person_rng = deps.axis_rng(parsed_seed_config, "person", seed, row_number) + expression_rng = deps.axis_rng(parsed_seed_config, "expression", seed, row_number) + clothing = request.clothing if request.clothing in ("full", "minimal", "random") else "full" + poses = request.poses if request.poses in ("standard", "evocative", "random") else "standard" + figure = request.figure if request.figure in ("curvy", "balanced", "bombshell", "random") else "curvy" + clothing = deps.pick_clothing_mode(content_rng, clothing, minimal_ratio) + poses = deps.pick_pose_mode(pose_axis_rng, poses, pose_ratio) + figure = deps.pick_figure_bias(person_rng, figure) + minimal_ratio = None + pose_ratio = None + expression_intensity, expression_intensity_source = deps.pick_expression_intensity( + expression_rng, + request.expression_intensity, + ) + + exact_custom_subcategory = bool( + subcategory and subcategory != deps.random_subcategory and " / " in subcategory + ) + + if category == "auto_full" and not exact_custom_subcategory: + category = deps.auto_full_choice(parsed_seed_config, seed, row_number) + + branch = "custom" + if category == "auto_weighted" and not exact_custom_subcategory: + branch = "auto_weighted" + row = deps.build_auto_weighted_row( + row_number, + start_index, + clothing, + ethnicity, + poses, + float(request.backside_bias), + figure, + bool(request.no_plus_women), + bool(request.no_black), + minimal_ratio, + pose_ratio, + seed, + ) + elif category in ("woman", "man", "couple", "group_or_layout") and not exact_custom_subcategory: + branch = "built_in" + row = deps.build_direct_builtin_row( + category, + row_number, + start_index, + clothing, + ethnicity, + poses, + float(request.backside_bias), + figure, + bool(request.no_plus_women), + bool(request.no_black), + minimal_ratio, + pose_ratio, + seed, + ) + else: + row = deps.build_custom_row( + category, + subcategory, + row_number, + start_index, + ethnicity, + poses, + figure, + bool(request.no_plus_women), + bool(request.no_black), + int(request.women_count), + int(request.men_count), + seed, + parsed_seed_config, + expression_enabled, + expression_intensity, + expression_intensity_source, + request.character_profile, + request.character_cast, + request.expression_phase, + request.hardcore_position_config, + parsed_location_config, + parsed_composition_config, + ) + + if row.get("source") == "built_in_generator": + row = deps.apply_location_config_to_legacy_row( + row, + parsed_location_config, + parsed_seed_config, + seed, + row_number, + ) + row = deps.apply_composition_config_to_legacy_row( + row, + parsed_composition_config, + parsed_seed_config, + seed, + row_number, + ) + if not expression_enabled: + row = deps.disable_row_expression(row, "disabled") + row = deps.apply_camera_config(row, request.camera_config) + active_trigger = request.trigger.strip() or deps.default_trigger + row = deps.normalize_prompt_row( + row, + active_trigger=active_trigger, + prepend_trigger_to_prompt=bool(request.prepend_trigger_to_prompt), + extra_positive=request.extra_positive, + extra_negative=request.extra_negative, + default_negative=deps.default_negative, + ) + row.setdefault("expression_intensity", expression_intensity) + row.setdefault("expression_intensity_source", expression_intensity_source) + return PromptBuildRoute( + row=row, + category=category, + subcategory=subcategory, + branch=branch, + parsed_seed_config=dict(parsed_seed_config), + expression_intensity=expression_intensity, + expression_intensity_source=expression_intensity_source, + ) + + +def build_prompt(request: PromptBuildRequest, deps: PromptBuildDependencies) -> dict[str, Any]: + return build_prompt_result(request, deps).row diff --git a/docs/prompt-architecture-improvement-plan.md b/docs/prompt-architecture-improvement-plan.md index 3e7c0ff..30552e1 100644 --- a/docs/prompt-architecture-improvement-plan.md +++ b/docs/prompt-architecture-improvement-plan.md @@ -120,6 +120,10 @@ Move or isolate later: Already isolated: +- single-prompt builder orchestration, including input normalization, seed-axis + setup, built-in/custom row routing, legacy location/composition handling, + camera application, and final prompt-row normalization, lives in + `builder_prompt_route.py`; `prompt_builder.py` keeps the public wrapper. - config-driven prompt-builder request parsing, helper-node config mapping, and direct `build_prompt` kwarg assembly live in `builder_config_route.py`; `prompt_builder.py` keeps the public wrapper. diff --git a/docs/prompt-pool-routing-map.md b/docs/prompt-pool-routing-map.md index c7a33fc..850937e 100644 --- a/docs/prompt-pool-routing-map.md +++ b/docs/prompt-pool-routing-map.md @@ -60,8 +60,8 @@ call the same core generation functions. | ComfyUI node | Python entry | What it owns | | --- | --- | --- | -| `SxCP Prompt Builder` | `build_prompt` | Direct single prompt generation. Can use built-in categories or JSON categories. | -| `SxCP Prompt Builder From Configs` | `build_prompt_from_configs` -> `builder_config_route.py` -> `build_prompt` | Same generator, but inputs come from category/cast/profile/filter helper nodes. | +| `SxCP Prompt Builder` | `build_prompt` -> `builder_prompt_route.py` | Direct single prompt generation. Can use built-in categories or JSON categories. | +| `SxCP Prompt Builder From Configs` | `build_prompt_from_configs` -> `builder_config_route.py` -> `build_prompt` -> `builder_prompt_route.py` | Same generator, but inputs come from category/cast/profile/filter helper nodes. | | `SxCP Insta/OF Prompt Pair` | `build_insta_of_pair` | Builds a softcore row and hardcore row with shared cast/continuity options. | | `SxCP Krea2 Formatter` | `format_krea2_prompt` | Converts metadata rows or pair metadata into Krea2-friendly prose. | | `SxCP SDXL Formatter` | `format_sdxl_prompt` | Converts metadata rows or pair metadata into SDXL/tag style prompts. | @@ -72,6 +72,7 @@ Core helper ownership: | Python module | What it owns | | --- | --- | | `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. | +| `builder_prompt_route.py` | Single-prompt builder orchestration, input normalization, seed-axis setup, built-in/custom row routing, legacy location/composition handling, camera application, and final prompt-row normalization. | | `builder_config_route.py` | Config-driven prompt-builder request parsing, category/cast/profile/filter helper-node mapping, and direct `build_prompt` kwarg assembly. | | `category_extensions.py` | JSON `pool_extensions`, legacy pool patching, built-in category choice lists, and category/subcategory UI choices. | | `category_template_metadata.py` | Object-style item-template metadata extraction, action/position family normalization, position-key normalization, key merging, and audit validation errors. | diff --git a/prompt_builder.py b/prompt_builder.py index a4fbcdd..0850abe 100644 --- a/prompt_builder.py +++ b/prompt_builder.py @@ -6,6 +6,7 @@ from typing import Any try: from . import builder_config_route as builder_config_route_policy + from . import builder_prompt_route as builder_prompt_route_policy from .category_library import ( compatible_entries as _compatible_entries, compatible_entry as _compatible_entry, @@ -53,6 +54,7 @@ try: ) except ImportError: # Allows local smoke tests with `python -c`. import builder_config_route as builder_config_route_policy + import builder_prompt_route as builder_prompt_route_policy from category_library import ( compatible_entries as _compatible_entries, compatible_entry as _compatible_entry, @@ -2460,6 +2462,35 @@ def _build_custom_row( return _assemble_custom_row(assembly_request) +def _prompt_build_dependencies() -> builder_prompt_route_policy.PromptBuildDependencies: + return builder_prompt_route_policy.PromptBuildDependencies( + default_trigger=g.TRIGGER, + default_negative=g.NEGATIVE_PROMPT, + random_subcategory=RANDOM_SUBCATEGORY, + apply_pool_extensions=apply_pool_extensions, + normalize_ethnicity_filter=normalize_ethnicity_filter, + is_false=_is_false, + ratio_or_none=_ratio_or_none, + parse_seed_config=_parse_seed_config, + parse_location_config=_parse_location_config, + parse_composition_config=_parse_composition_config, + axis_rng=_axis_rng, + pick_clothing_mode=_pick_clothing_mode, + pick_pose_mode=_pick_pose_mode, + pick_figure_bias=_pick_figure_bias, + pick_expression_intensity=_pick_expression_intensity, + auto_full_choice=_auto_full_choice, + build_auto_weighted_row=_build_auto_weighted_row, + build_direct_builtin_row=_build_direct_builtin_row, + build_custom_row=_build_custom_row, + apply_location_config_to_legacy_row=row_location_policy.apply_location_config_to_legacy_row, + apply_composition_config_to_legacy_row=row_location_policy.apply_composition_config_to_legacy_row, + disable_row_expression=_disable_row_expression, + apply_camera_config=_apply_camera_config, + normalize_prompt_row=row_policy.normalize_prompt_row, + ) + + def build_prompt( category: str, subcategory: str, @@ -2492,123 +2523,41 @@ def build_prompt( location_config: str | dict[str, Any] | None = None, composition_config: str | dict[str, Any] | None = None, ) -> dict[str, Any]: - apply_pool_extensions() - row_number = max(1, int(row_number)) - start_index = max(1, int(start_index)) - seed = int(seed) - ethnicity = normalize_ethnicity_filter(ethnicity, "any") - expression_enabled = not _is_false(expression_enabled) - minimal_ratio = _ratio_or_none(minimal_clothing_ratio) - pose_ratio = _ratio_or_none(standard_pose_ratio) - parsed_seed_config = _parse_seed_config(seed_config) - parsed_location_config = _parse_location_config(location_config) - parsed_composition_config = _parse_composition_config(composition_config) - content_rng = _axis_rng(parsed_seed_config, "content", seed, row_number) - pose_axis_rng = _axis_rng(parsed_seed_config, "pose", seed, row_number) - person_rng = _axis_rng(parsed_seed_config, "person", seed, row_number) - expression_rng = _axis_rng(parsed_seed_config, "expression", seed, row_number) - clothing = clothing if clothing in ("full", "minimal", "random") else "full" - poses = poses if poses in ("standard", "evocative", "random") else "standard" - figure = figure if figure in ("curvy", "balanced", "bombshell", "random") else "curvy" - clothing = _pick_clothing_mode(content_rng, clothing, minimal_ratio) - poses = _pick_pose_mode(pose_axis_rng, poses, pose_ratio) - figure = _pick_figure_bias(person_rng, figure) - minimal_ratio = None - pose_ratio = None - expression_intensity, expression_intensity_source = _pick_expression_intensity(expression_rng, expression_intensity) - - exact_custom_subcategory = bool(subcategory and subcategory != RANDOM_SUBCATEGORY and " / " in subcategory) - - if category == "auto_full" and not exact_custom_subcategory: - category = _auto_full_choice(parsed_seed_config, seed, row_number) - - if category == "auto_weighted" and not exact_custom_subcategory: - row = _build_auto_weighted_row( - row_number, - start_index, - clothing, - ethnicity, - poses, - float(backside_bias), - figure, - bool(no_plus_women), - bool(no_black), - minimal_ratio, - pose_ratio, - seed, - ) - elif category in ("woman", "man", "couple", "group_or_layout") and not exact_custom_subcategory: - row = _build_direct_builtin_row( - category, - row_number, - start_index, - clothing, - ethnicity, - poses, - float(backside_bias), - figure, - bool(no_plus_women), - bool(no_black), - minimal_ratio, - pose_ratio, - seed, - ) - else: - row = _build_custom_row( - category, - subcategory, - row_number, - start_index, - ethnicity, - poses, - figure, - bool(no_plus_women), - bool(no_black), - int(women_count), - int(men_count), - seed, - parsed_seed_config, - expression_enabled, - expression_intensity, - expression_intensity_source, - character_profile, - character_cast, - expression_phase, - hardcore_position_config, - parsed_location_config, - parsed_composition_config, - ) - - if row.get("source") == "built_in_generator": - row = row_location_policy.apply_location_config_to_legacy_row( - row, - parsed_location_config, - parsed_seed_config, - seed, - row_number, - ) - row = row_location_policy.apply_composition_config_to_legacy_row( - row, - parsed_composition_config, - parsed_seed_config, - seed, - row_number, - ) - if not expression_enabled: - row = _disable_row_expression(row, "disabled") - row = _apply_camera_config(row, camera_config) - active_trigger = trigger.strip() or g.TRIGGER - row = row_policy.normalize_prompt_row( - row, - active_trigger=active_trigger, - prepend_trigger_to_prompt=bool(prepend_trigger_to_prompt), - extra_positive=extra_positive, - extra_negative=extra_negative, - default_negative=g.NEGATIVE_PROMPT, + return builder_prompt_route_policy.build_prompt( + builder_prompt_route_policy.PromptBuildRequest( + category=category, + subcategory=subcategory, + row_number=row_number, + start_index=start_index, + seed=seed, + clothing=clothing, + ethnicity=ethnicity, + poses=poses, + backside_bias=backside_bias, + figure=figure, + no_plus_women=no_plus_women, + no_black=no_black, + minimal_clothing_ratio=minimal_clothing_ratio, + standard_pose_ratio=standard_pose_ratio, + trigger=trigger, + prepend_trigger_to_prompt=prepend_trigger_to_prompt, + extra_positive=extra_positive, + extra_negative=extra_negative, + seed_config=seed_config, + women_count=women_count, + men_count=men_count, + camera_config=camera_config, + expression_intensity=expression_intensity, + character_profile=character_profile, + character_cast=character_cast, + expression_enabled=expression_enabled, + expression_phase=expression_phase, + hardcore_position_config=hardcore_position_config, + location_config=location_config, + composition_config=composition_config, + ), + _prompt_build_dependencies(), ) - row.setdefault("expression_intensity", expression_intensity) - row.setdefault("expression_intensity_source", expression_intensity_source) - return row def _prompt_from_configs_dependencies() -> builder_config_route_policy.PromptFromConfigsDependencies: diff --git a/tools/prompt_smoke.py b/tools/prompt_smoke.py index a32c8aa..4f4ec0b 100644 --- a/tools/prompt_smoke.py +++ b/tools/prompt_smoke.py @@ -29,6 +29,7 @@ import caption_metadata_routes # noqa: E402 import caption_policy # noqa: E402 import caption_text_policy # noqa: E402 import builder_config_route # noqa: E402 +import builder_prompt_route # noqa: E402 import cast_context # noqa: E402 import category_extensions # noqa: E402 import category_template_metadata # noqa: E402 @@ -605,6 +606,75 @@ def smoke_config_route_location_theme() -> None: _expect_formatter_outputs(row, "config_route_location_theme", target="single") +def smoke_builder_prompt_route_policy() -> None: + seed_config_json = pb.build_seed_lock_config_json(base_seed=3501, reroll_axis="content", reroll_seed=3502) + request = builder_prompt_route.PromptBuildRequest( + category="Casual clothes", + subcategory="Casual clothes / Smart casual", + row_number=3, + start_index=8, + seed=3501, + clothing="random", + ethnicity="french_european", + poses="random", + backside_bias=0.2, + figure="random", + no_plus_women=False, + no_black=False, + minimal_clothing_ratio=0.3, + standard_pose_ratio=0.4, + trigger="sxcpinup_coloredpencil", + prepend_trigger_to_prompt=True, + extra_positive="typed builder route marker", + extra_negative="typed builder negative marker", + seed_config=seed_config_json, + women_count=1, + men_count=0, + camera_config=_orbit_camera(horizontal_angle=45, vertical_angle=0, zoom=5.5), + expression_intensity=0.6, + expression_enabled=True, + ) + typed_route = builder_prompt_route.build_prompt_result(request, pb._prompt_build_dependencies()) + legacy_row = pb.build_prompt( + category=request.category, + subcategory=request.subcategory, + row_number=request.row_number, + start_index=request.start_index, + seed=request.seed, + clothing=request.clothing, + ethnicity=request.ethnicity, + poses=request.poses, + backside_bias=request.backside_bias, + figure=request.figure, + no_plus_women=request.no_plus_women, + no_black=request.no_black, + minimal_clothing_ratio=request.minimal_clothing_ratio, + standard_pose_ratio=request.standard_pose_ratio, + trigger=request.trigger, + prepend_trigger_to_prompt=request.prepend_trigger_to_prompt, + extra_positive=request.extra_positive, + extra_negative=request.extra_negative, + seed_config=request.seed_config, + women_count=request.women_count, + men_count=request.men_count, + camera_config=request.camera_config, + expression_intensity=request.expression_intensity, + expression_enabled=request.expression_enabled, + ) + _expect(typed_route.row == legacy_row, "Typed builder prompt route should match public wrapper output") + _expect(typed_route.category == "Casual clothes", "Builder prompt route changed category") + _expect(typed_route.subcategory == "Casual clothes / Smart casual", "Builder prompt route changed subcategory") + _expect(typed_route.branch == "custom", "Builder prompt route should use custom branch for category JSON route") + _expect(typed_route.parsed_seed_config.get("content_seed") == 3502, "Builder prompt route lost seed config") + _expect("typed builder route marker" in typed_route.row.get("prompt", ""), "Builder prompt route lost extra positive") + _expect("typed builder negative marker" in typed_route.row.get("negative_prompt", ""), "Builder prompt route lost extra negative") + _expect( + "45-degree front-right quarter view" in typed_route.row.get("camera_directive", ""), + "Builder prompt route lost camera config", + ) + _expect_trigger_once("builder_prompt_route_policy.prompt", typed_route.row.get("prompt"), "sxcpinup_coloredpencil") + + def smoke_builder_config_route_policy() -> None: category_config = pb.build_category_config_json("women_casual", "Casual clothes / Smart casual") cast_config = pb.build_cast_config_json("solo_woman") @@ -5422,6 +5492,7 @@ SMOKE_CASES: list[tuple[str, Callable[[], None]]] = [ ("camera_scene_single", smoke_camera_scene_single), ("row_camera_policy", smoke_row_camera_policy), ("config_route_location_theme", smoke_config_route_location_theme), + ("builder_prompt_route_policy", smoke_builder_prompt_route_policy), ("builder_config_route_policy", smoke_builder_config_route_policy), ("krea_normal_row_routes", smoke_krea_normal_row_routes), ("krea_row_fields_policy", smoke_krea_row_fields_policy),