diff --git a/builder_config_route.py b/builder_config_route.py new file mode 100644 index 0000000..c1e9de4 --- /dev/null +++ b/builder_config_route.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Callable + + +@dataclass(frozen=True) +class PromptFromConfigsRequest: + row_number: int + start_index: int + seed: int + category_config: str | dict[str, Any] | None = "" + cast_config: str | dict[str, Any] | None = "" + generation_profile: str | dict[str, Any] | None = "" + filter_config: str | dict[str, Any] | None = "" + seed_config: str | dict[str, Any] | None = "" + camera_config: str | dict[str, Any] | None = "" + character_profile: str | dict[str, Any] | None = "" + character_cast: str | dict[str, Any] | list[Any] | None = "" + hardcore_position_config: str | dict[str, Any] | None = "" + location_config: str | dict[str, Any] | None = "" + composition_config: str | dict[str, Any] | None = "" + extra_positive: str = "" + extra_negative: str = "" + + +@dataclass(frozen=True) +class PromptFromConfigsRoute: + row: dict[str, Any] + category: str + subcategory: str + cast: dict[str, Any] + profile: dict[str, Any] + filters: dict[str, Any] + build_kwargs: dict[str, Any] + + +@dataclass(frozen=True) +class PromptFromConfigsDependencies: + parse_category_config: Callable[[str | dict[str, Any] | None], tuple[str, str]] + parse_cast_config: Callable[[str | dict[str, Any] | None], dict[str, Any]] + parse_generation_profile: Callable[[str | dict[str, Any] | None], dict[str, Any]] + parse_filter_config: Callable[[str | dict[str, Any] | None], dict[str, Any]] + build_prompt: Callable[..., dict[str, Any]] + + +def build_prompt_from_configs_result( + request: PromptFromConfigsRequest, + deps: PromptFromConfigsDependencies, +) -> PromptFromConfigsRoute: + category, subcategory = deps.parse_category_config(request.category_config) + cast = deps.parse_cast_config(request.cast_config) + profile = deps.parse_generation_profile(request.generation_profile) + filters = deps.parse_filter_config(request.filter_config) + build_kwargs: dict[str, Any] = { + "category": category, + "subcategory": subcategory, + "row_number": request.row_number, + "start_index": request.start_index, + "seed": request.seed, + "clothing": profile["clothing"], + "ethnicity": filters["ethnicity"], + "poses": profile["poses"], + "expression_enabled": profile["expression_enabled"], + "expression_intensity": profile["expression_intensity"], + "backside_bias": profile["backside_bias"], + "figure": filters["figure"], + "no_plus_women": filters["no_plus_women"], + "no_black": filters["no_black"], + "women_count": int(cast["women_count"]), + "men_count": int(cast["men_count"]), + "minimal_clothing_ratio": profile["minimal_clothing_ratio"], + "standard_pose_ratio": profile["standard_pose_ratio"], + "trigger": profile["trigger"], + "prepend_trigger_to_prompt": profile["prepend_trigger_to_prompt"], + "extra_positive": request.extra_positive or "", + "extra_negative": request.extra_negative or "", + "seed_config": request.seed_config or "", + "camera_config": request.camera_config or "", + "character_profile": request.character_profile or "", + "character_cast": request.character_cast or "", + "hardcore_position_config": request.hardcore_position_config or "", + "location_config": request.location_config or "", + "composition_config": request.composition_config or "", + } + return PromptFromConfigsRoute( + row=deps.build_prompt(**build_kwargs), + category=category, + subcategory=subcategory, + cast=dict(cast), + profile=dict(profile), + filters=dict(filters), + build_kwargs=build_kwargs, + ) + + +def build_prompt_from_configs( + request: PromptFromConfigsRequest, + deps: PromptFromConfigsDependencies, +) -> dict[str, Any]: + return build_prompt_from_configs_result(request, deps).row diff --git a/docs/prompt-architecture-improvement-plan.md b/docs/prompt-architecture-improvement-plan.md index 50cf267..3e7c0ff 100644 --- a/docs/prompt-architecture-improvement-plan.md +++ b/docs/prompt-architecture-improvement-plan.md @@ -120,6 +120,9 @@ Move or isolate later: Already isolated: +- 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. - JSON category loading, subcategory normalization, named scene/expression/ composition pool loading, cast compatibility filtering, exact subcategory lookup, and inheritance-based pool merging live in `category_library.py`. diff --git a/docs/prompt-pool-routing-map.md b/docs/prompt-pool-routing-map.md index a5de355..c7a33fc 100644 --- a/docs/prompt-pool-routing-map.md +++ b/docs/prompt-pool-routing-map.md @@ -61,7 +61,7 @@ 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` -> `build_prompt` | Same generator, but inputs come from category/cast/profile/filter helper nodes. | +| `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 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_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. | | `row_item.py` | Row item selection, weighted item/pair choice, item-template axis filling, and oral/outercourse axis compatibility filters. | diff --git a/prompt_builder.py b/prompt_builder.py index 9344c06..a4fbcdd 100644 --- a/prompt_builder.py +++ b/prompt_builder.py @@ -5,6 +5,7 @@ from pathlib import Path from typing import Any try: + from . import builder_config_route as builder_config_route_policy from .category_library import ( compatible_entries as _compatible_entries, compatible_entry as _compatible_entry, @@ -51,6 +52,7 @@ try: sanitize_hardcore_environment_anchors as _sanitize_hardcore_environment_anchors, ) except ImportError: # Allows local smoke tests with `python -c`. + import builder_config_route as builder_config_route_policy from category_library import ( compatible_entries as _compatible_entries, compatible_entry as _compatible_entry, @@ -2609,6 +2611,16 @@ def build_prompt( return row +def _prompt_from_configs_dependencies() -> builder_config_route_policy.PromptFromConfigsDependencies: + return builder_config_route_policy.PromptFromConfigsDependencies( + parse_category_config=_parse_category_config, + parse_cast_config=_parse_cast_config, + parse_generation_profile=_parse_generation_profile, + parse_filter_config=_parse_filter_config, + build_prompt=build_prompt, + ) + + def build_prompt_from_configs( row_number: int, start_index: int, @@ -2627,40 +2639,26 @@ def build_prompt_from_configs( extra_positive: str = "", extra_negative: str = "", ) -> dict[str, Any]: - category, subcategory = _parse_category_config(category_config) - cast = _parse_cast_config(cast_config) - profile = _parse_generation_profile(generation_profile) - filters = _parse_filter_config(filter_config) - return build_prompt( - category=category, - subcategory=subcategory, - row_number=row_number, - start_index=start_index, - seed=seed, - clothing=profile["clothing"], - ethnicity=filters["ethnicity"], - poses=profile["poses"], - expression_enabled=profile["expression_enabled"], - expression_intensity=profile["expression_intensity"], - backside_bias=profile["backside_bias"], - figure=filters["figure"], - no_plus_women=filters["no_plus_women"], - no_black=filters["no_black"], - women_count=int(cast["women_count"]), - men_count=int(cast["men_count"]), - minimal_clothing_ratio=profile["minimal_clothing_ratio"], - standard_pose_ratio=profile["standard_pose_ratio"], - trigger=profile["trigger"], - prepend_trigger_to_prompt=profile["prepend_trigger_to_prompt"], - extra_positive=extra_positive or "", - extra_negative=extra_negative or "", - seed_config=seed_config or "", - camera_config=camera_config or "", - character_profile=character_profile or "", - character_cast=character_cast or "", - hardcore_position_config=hardcore_position_config or "", - location_config=location_config or "", - composition_config=composition_config or "", + return builder_config_route_policy.build_prompt_from_configs( + builder_config_route_policy.PromptFromConfigsRequest( + row_number=row_number, + start_index=start_index, + seed=seed, + category_config=category_config, + cast_config=cast_config, + generation_profile=generation_profile, + filter_config=filter_config, + seed_config=seed_config, + camera_config=camera_config, + character_profile=character_profile, + character_cast=character_cast, + hardcore_position_config=hardcore_position_config, + location_config=location_config, + composition_config=composition_config, + extra_positive=extra_positive, + extra_negative=extra_negative, + ), + _prompt_from_configs_dependencies(), ) diff --git a/tools/prompt_smoke.py b/tools/prompt_smoke.py index a41365d..a32c8aa 100644 --- a/tools/prompt_smoke.py +++ b/tools/prompt_smoke.py @@ -28,6 +28,7 @@ import caption_naturalizer # noqa: E402 import caption_metadata_routes # noqa: E402 import caption_policy # noqa: E402 import caption_text_policy # noqa: E402 +import builder_config_route # noqa: E402 import cast_context # noqa: E402 import category_extensions # noqa: E402 import category_template_metadata # noqa: E402 @@ -604,6 +605,62 @@ def smoke_config_route_location_theme() -> None: _expect_formatter_outputs(row, "config_route_location_theme", target="single") +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") + generation_profile = pb.build_generation_profile_json( + profile="casual_clean", + trigger_policy="prepend_trigger", + ) + filter_config = pb.build_filter_config_json( + ethnicity="french_european", + figure="balanced", + ) + seed_config_json = pb.build_seed_lock_config_json(base_seed=3401, reroll_axis="scene", reroll_seed=3402) + request = builder_config_route.PromptFromConfigsRequest( + row_number=2, + start_index=5, + seed=3401, + category_config=category_config, + cast_config=cast_config, + generation_profile=generation_profile, + filter_config=filter_config, + seed_config=seed_config_json, + extra_positive="clean route marker", + extra_negative="bad route marker", + ) + typed_route = builder_config_route.build_prompt_from_configs_result( + request, + pb._prompt_from_configs_dependencies(), + ) + legacy_row = pb.build_prompt_from_configs( + row_number=request.row_number, + start_index=request.start_index, + seed=request.seed, + category_config=category_config, + cast_config=cast_config, + generation_profile=generation_profile, + filter_config=filter_config, + seed_config=seed_config_json, + extra_positive=request.extra_positive, + extra_negative=request.extra_negative, + ) + _expect(typed_route.row == legacy_row, "Prompt Builder From Configs route should match public wrapper output") + _expect(typed_route.category == "Casual clothes", "Config route lost category preset") + _expect(typed_route.subcategory == "Casual clothes / Smart casual", "Config route lost requested subcategory") + _expect(typed_route.cast["women_count"] == 1 and typed_route.cast["men_count"] == 0, "Config route lost cast preset") + _expect(typed_route.profile["trigger"] == "sxcpinup_coloredpencil", "Config route lost generation profile trigger") + _expect(typed_route.filters["ethnicity"] == "french_european", "Config route lost filter ethnicity") + kwargs = typed_route.build_kwargs + _expect(kwargs["category"] == typed_route.category, "Config route build kwargs category drifted") + _expect(kwargs["subcategory"] == typed_route.subcategory, "Config route build kwargs subcategory drifted") + _expect(kwargs["women_count"] == 1 and kwargs["men_count"] == 0, "Config route build kwargs cast counts drifted") + _expect(kwargs["seed_config"] == seed_config_json, "Config route build kwargs seed config drifted") + _expect(kwargs["extra_positive"] == "clean route marker", "Config route build kwargs extra positive drifted") + _expect("clean route marker" in typed_route.row.get("prompt", ""), "Config route row lost extra positive") + _expect("bad route marker" in typed_route.row.get("negative_prompt", ""), "Config route row lost extra negative") + + def smoke_krea_normal_row_routes() -> None: single = { "subject_type": "woman", @@ -5365,6 +5422,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_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), ("location_config_policy", smoke_location_config_policy),