diff --git a/docs/prompt-architecture-improvement-plan.md b/docs/prompt-architecture-improvement-plan.md index 8483da7..f53c2a6 100644 --- a/docs/prompt-architecture-improvement-plan.md +++ b/docs/prompt-architecture-improvement-plan.md @@ -155,6 +155,10 @@ Already isolated: - row subject-context routing for single, couple, configured-cast, group, and layout subjects lives in `subject_context.py`; it combines appearance policy, cast metadata, and generator subject pools behind one row-facing entry point. +- row subject route orchestration, character slot/profile precedence, + configured-cast POV labels, visible cast descriptor collection, and + descriptor prompt cleanup live in `row_subject_route.py`; + `prompt_builder.py` keeps a public delegate wrapper. - ethnicity/filter choices, advanced filter JSON, ethnicity-list JSON, filter parsing, and ethnicity normalization live in `filter_config.py`; character routes and builder filters use `prompt_builder.py` delegate wrappers. diff --git a/docs/prompt-pool-routing-map.md b/docs/prompt-pool-routing-map.md index 498c0d6..dc1d9b4 100644 --- a/docs/prompt-pool-routing-map.md +++ b/docs/prompt-pool-routing-map.md @@ -86,6 +86,7 @@ Core helper ownership: | `generation_profile_config.py` | Generation profile presets, profile option overrides, trigger policy, expression/pose/clothing config normalization, and profile config parsing. | | `seed_config.py` | Seed axis salts/aliases, seed mode choices, global/axis lock JSON builders, seed config parsing, row seed math, and deterministic axis RNG construction. | | `subject_context.py` | Row subject-context routing for single, couple, configured-cast, group, and layout subjects, combining appearance policy, cast metadata, and generator subject pools. | +| `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. | @@ -498,12 +499,12 @@ plain prompt text. When debugging, inspect these fields before editing pools. | `camera_config` | Camera nodes/parser | Krea/SDXL/debug | Structured camera settings. | | `camera_directive` | `_camera_directive` | Krea/Naturalizer/prompt text | Human camera sentence. Suppressed for POV. | | `camera_scene_directive` | scene-camera adapter | Krea/Naturalizer/prompt text | Location-aware camera layout sentence. | -| `subject_type`, `subject_phrase` | Subject/context builder | Formatters | Single/couple/group/configured cast route. | -| `women_count`, `men_count`, `person_count` | Cast route | Pair/formatters/debug | Effective cast counts. | -| `cast_descriptors`, `cast_descriptor_text` | Character/cast route | Krea/SDXL/Naturalizer | Visible cast descriptors. | -| `character_cast_slots` | Character slot chain | POV/camera/formatters | Raw configured slots. | -| `character_slot_status`, `character_profile_status` | Character/profile application | Debug | Explains whether slot/profile was applied or skipped. | -| `pov_character_labels` | Character slot presence mode | Krea/prompt/camera | Labels omitted from visible cast and rewritten as first-person POV. | +| `subject_type`, `subject_phrase` | `row_subject_route.resolve_subject_route` | Formatters | Single/couple/group/configured cast route. | +| `women_count`, `men_count`, `person_count` | `row_subject_route.resolve_subject_route` | Pair/formatters/debug | Effective cast counts. | +| `cast_descriptors`, `cast_descriptor_text` | `row_subject_route.resolve_subject_route` | Krea/SDXL/Naturalizer | Visible cast descriptors. | +| `character_cast_slots` | `row_subject_route.resolve_subject_route` | POV/camera/formatters | Raw configured slots. | +| `character_slot_status`, `character_profile_status` | `row_subject_route.resolve_subject_route` | Debug | Explains whether slot/profile was applied or skipped. | +| `pov_character_labels` | `row_subject_route.resolve_subject_route` | Krea/prompt/camera | Labels omitted from visible cast and rewritten as first-person POV. | | `hardcore_position_config` | Hardcore position/filter nodes | Debug | Active hardcore family/position/action/interaction constraints, including `interaction_only` and `manual_only`. | | `negative_prompt` | Category/pair/default negative route | Formatter output | Base negative text before formatter extras. | | `trigger` | Builder input | Formatter/fallback/debug | Active trigger after fallback to default. | diff --git a/prompt_builder.py b/prompt_builder.py index 8082dea..2650d97 100644 --- a/prompt_builder.py +++ b/prompt_builder.py @@ -43,6 +43,7 @@ try: from . import row_pools as row_pool_policy from . import row_rendering as row_rendering_policy from . import row_route_metadata as row_route_policy + from . import row_subject_route as row_subject_route_policy from . import seed_config as seed_policy from . import subject_context as subject_context_policy from .hardcore_text_cleanup import ( @@ -89,6 +90,7 @@ except ImportError: # Allows local smoke tests with `python -c`. import row_pools as row_pool_policy import row_rendering as row_rendering_policy import row_route_metadata as row_route_policy + import row_subject_route as row_subject_route_policy import seed_config as seed_policy import subject_context as subject_context_policy from hardcore_text_cleanup import ( @@ -1960,6 +1962,37 @@ def _subject_context( ) +def _subject_route( + *, + subject_type: str, + seed_config: dict[str, int], + seed: int, + row_number: int, + ethnicity: str, + figure: str, + no_plus_women: bool, + no_black: bool, + women_count: int, + men_count: int, + character_profile: str | dict[str, Any] | None = None, + character_cast: str | dict[str, Any] | list[Any] | None = None, +) -> dict[str, Any]: + return row_subject_route_policy.resolve_subject_route( + subject_type=subject_type, + seed_config=seed_config, + seed=seed, + row_number=row_number, + ethnicity=ethnicity, + figure=figure, + no_plus_women=no_plus_women, + no_black=no_black, + women_count=women_count, + men_count=men_count, + character_profile=character_profile, + character_cast=character_cast, + ) + + def _scene_pool( category: dict[str, Any], subcategory: dict[str, Any], @@ -2020,7 +2053,6 @@ def _build_custom_row( location_config: str | dict[str, Any] | None = None, composition_config: str | dict[str, Any] | None = None, ) -> dict[str, Any]: - person_rng = _axis_rng(seed_config, "person", seed, row_number) scene_rng = _axis_rng(seed_config, "scene", seed, row_number) pose_rng = _axis_rng(seed_config, "pose", seed, row_number) role_rng = _axis_rng(seed_config, "role", seed, row_number) @@ -2054,41 +2086,35 @@ def _build_custom_row( item_formatter_hints = dict(category_route.get("formatter_hints") or {}) is_pose_category = bool(category_route.get("is_pose_category")) subject_type = str(_merged_field(category, subcategory, item, "subject_type", "single_any")) - context = _subject_context(person_rng, subject_type, ethnicity, figure, no_plus_women, no_black, women_count, men_count) - character_slots = _parse_character_cast(character_cast) - character_slot_map = _character_slot_label_map(character_slots) - applied_slot: dict[str, Any] = {} - slot_status = "none" - if context.get("subject_type") in ("woman", "man"): - slot_label = "Woman A" if context["subject_type"] == "woman" else "Man A" - if slot_label in character_slot_map: - context, applied_slot = _character_context_for_label( - slot_label, - character_slot_map, - person_rng, - ethnicity, - figure, - no_plus_women, - no_black, - ) - slot_status = f"applied:{slot_label}" - applied_profile, profile_status = {}, "skipped_character_slot" - else: - context, applied_profile, profile_status = _apply_character_profile_to_context(context, character_profile) - else: - context, applied_profile, profile_status = _apply_character_profile_to_context(context, character_profile) - subject_type = context["subject_type"] - pov_character_labels = ( - _pov_character_labels(character_slot_map, men_count) - if subject_type == "configured_cast" - else [] + subject_route = _subject_route( + subject_type=subject_type, + seed_config=seed_config, + seed=seed, + row_number=row_number, + ethnicity=ethnicity, + figure=figure, + no_plus_women=no_plus_women, + no_black=no_black, + women_count=women_count, + men_count=men_count, + character_profile=character_profile, + character_cast=character_cast, ) + context = dict(subject_route["context"]) + subject_type = str(subject_route.get("subject_type") or context.get("subject_type") or subject_type) + character_slots = list(subject_route.get("character_slots") or []) + character_slot_map = dict(subject_route.get("character_slot_map") or {}) + applied_slot = dict(subject_route.get("applied_slot") or {}) + slot_status = str(subject_route.get("character_slot_status") or "none") + applied_profile = dict(subject_route.get("applied_profile") or {}) + profile_status = str(subject_route.get("character_profile_status") or "none") + pov_character_labels = list(subject_route.get("pov_character_labels") or []) + cast_descriptors = list(subject_route.get("cast_descriptors") or []) + cast_descriptor_text = str(subject_route.get("cast_descriptor_text") or "") source_role_graph = build_hardcore_role_graph(role_rng, subcategory, context, item_axis_values, pov_character_labels) 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) - cast_descriptors: list[str] = [] - cast_descriptor_text = "" expression_intensity_source = expression_intensity_source or "input" expression_disabled = not bool(expression_enabled) if expression_disabled: @@ -2113,20 +2139,6 @@ def _build_custom_row( ) if expression_intensity is None: expression_disabled = True - if subject_type == "configured_cast" and character_slots: - cast_descriptors, _descriptor_slots = _cast_descriptor_entries( - seed_config, - seed, - row_number, - ethnicity, - figure, - no_plus_women, - no_black, - women_count, - men_count, - character_slots, - ) - cast_descriptor_text = _insta_of_prompt_cast_descriptors("; ".join(cast_descriptors)) scene_slug, scene = _choose_pair( scene_rng, diff --git a/row_subject_route.py b/row_subject_route.py new file mode 100644 index 0000000..02f928c --- /dev/null +++ b/row_subject_route.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +from typing import Any + +try: + from . import cast_context as cast_context_policy + from . import character_appearance as character_appearance_policy + from . import character_profile as character_profile_policy + from . import character_slot as character_slot_policy + from . import pair_cast + from . import pov_policy + from . import seed_config as seed_policy + from . import subject_context as subject_context_policy +except ImportError: # Allows local smoke tests from the repository root. + import cast_context as cast_context_policy + import character_appearance as character_appearance_policy + import character_profile as character_profile_policy + import character_slot as character_slot_policy + import pair_cast + import pov_policy + import seed_config as seed_policy + import subject_context as subject_context_policy + + +def resolve_subject_route( + *, + subject_type: str, + seed_config: dict[str, int], + seed: int, + row_number: int, + ethnicity: str, + figure: str, + no_plus_women: bool, + no_black: bool, + women_count: int, + men_count: int, + character_profile: str | dict[str, Any] | None = None, + character_cast: str | dict[str, Any] | list[Any] | None = None, +) -> dict[str, Any]: + person_rng = seed_policy.axis_rng(seed_config, "person", seed, row_number) + context = subject_context_policy.subject_context( + person_rng, + subject_type, + ethnicity, + figure, + no_plus_women, + no_black, + women_count, + men_count, + ) + character_slots = character_slot_policy.parse_character_cast(character_cast) + character_slot_map = cast_context_policy.character_slot_label_map(character_slots) + applied_slot: dict[str, Any] = {} + slot_status = "none" + if context.get("subject_type") in ("woman", "man"): + slot_label = "Woman A" if context["subject_type"] == "woman" else "Man A" + if slot_label in character_slot_map: + context, applied_slot = character_appearance_policy.character_context_for_label( + slot_label, + character_slot_map, + person_rng, + ethnicity, + figure, + no_plus_women, + no_black, + ) + slot_status = f"applied:{slot_label}" + applied_profile, profile_status = {}, "skipped_character_slot" + else: + context, applied_profile, profile_status = character_profile_policy.apply_character_profile_to_context( + context, + character_profile, + ) + else: + context, applied_profile, profile_status = character_profile_policy.apply_character_profile_to_context( + context, + character_profile, + ) + + resolved_subject_type = str(context.get("subject_type") or subject_type) + pov_character_labels = ( + pov_policy.pov_character_labels(character_slot_map, men_count) + if resolved_subject_type == "configured_cast" + else [] + ) + cast_descriptors: list[str] = [] + cast_descriptor_text = "" + if resolved_subject_type == "configured_cast" and character_slots: + cast_descriptors, _descriptor_slots = pair_cast.cast_descriptor_entries_from_slots( + seed_config=seed_config, + seed=seed, + row_number=row_number, + ethnicity=ethnicity, + figure=figure, + no_plus_women=no_plus_women, + no_black=no_black, + women_count=women_count, + men_count=men_count, + character_slots=character_slots, + character_slot_map=character_slot_map, + primary_descriptor="", + axis_rng=seed_policy.axis_rng, + character_context_for_label=character_appearance_policy.character_context_for_label, + slot_is_pov=pov_policy.slot_is_pov, + ) + cast_descriptor_text = pair_cast.prompt_cast_descriptors("; ".join(cast_descriptors)) + + return { + "context": context, + "subject_type": resolved_subject_type, + "character_slots": character_slots, + "character_slot_map": character_slot_map, + "applied_slot": applied_slot or {}, + "character_slot_status": slot_status, + "applied_profile": applied_profile or {}, + "character_profile_status": profile_status, + "pov_character_labels": pov_character_labels, + "cast_descriptors": cast_descriptors, + "cast_descriptor_text": cast_descriptor_text, + } diff --git a/tools/prompt_smoke.py b/tools/prompt_smoke.py index 352d4e8..910c2cf 100644 --- a/tools/prompt_smoke.py +++ b/tools/prompt_smoke.py @@ -60,6 +60,7 @@ import row_location # noqa: E402 import row_pools # noqa: E402 import row_rendering # noqa: E402 import row_route_metadata # noqa: E402 +import row_subject_route # noqa: E402 import server_routes # noqa: E402 import sdxl_formatter # noqa: E402 import sdxl_presets # noqa: E402 @@ -1061,6 +1062,84 @@ def smoke_category_cast_config_policy() -> None: _expect((empty_cast.get("women_count"), empty_cast.get("men_count")) == (1, 0), "Empty custom cast was not corrected") +def smoke_row_subject_route_policy() -> None: + seed_cfg = seed_config.parse_seed_config({}) + slot_cast = pb.build_character_slot_json( + subject_type="woman", + label="A", + age="32-year-old adult", + ethnicity="western_european", + figure="balanced", + body="slim", + hair="short silver bob", + eyes="gray eyes", + descriptor_detail="full", + )["character_cast"] + profile = { + "profile_type": "character", + "subject_type": "woman", + "age": "45-year-old adult", + "body": "average", + "body_phrase": "average figure", + "skin": "profile skin", + "hair": "profile hair", + "eyes": "profile eyes", + } + route = row_subject_route.resolve_subject_route( + subject_type="woman", + seed_config=seed_cfg, + seed=501, + row_number=1, + ethnicity="any", + figure="balanced", + no_plus_women=False, + no_black=False, + women_count=1, + men_count=0, + character_profile=profile, + character_cast=slot_cast, + ) + delegated = pb._subject_route( + subject_type="woman", + seed_config=seed_cfg, + seed=501, + row_number=1, + ethnicity="any", + figure="balanced", + no_plus_women=False, + no_black=False, + women_count=1, + men_count=0, + character_profile=profile, + character_cast=slot_cast, + ) + _expect(delegated == route, "Prompt builder subject route should delegate to row_subject_route") + _expect(route["subject_type"] == "woman", "Subject route changed single-woman subject type") + _expect(route["character_slot_status"] == "applied:Woman A", "Subject route did not apply matching Woman A slot") + _expect(route["character_profile_status"] == "skipped_character_slot", "Subject route should skip profile when slot applies") + _expect(route["context"].get("age") == "32-year-old adult", "Subject route lost slot age override") + _expect(route["context"].get("hair") == "short silver bob", "Subject route lost slot hair override") + _expect(route["applied_profile"] == {}, "Subject route should not apply profile over matching slot") + + cast_route = row_subject_route.resolve_subject_route( + subject_type="configured_cast", + seed_config=seed_cfg, + seed=502, + row_number=1, + ethnicity="western_european", + figure="balanced", + no_plus_women=False, + no_black=False, + women_count=1, + men_count=1, + character_cast=_character_cast(pov_man=True), + ) + _expect(cast_route["subject_type"] == "configured_cast", "Configured-cast subject route changed subject type") + _expect(cast_route["pov_character_labels"] == ["Man A"], "Subject route lost configured-cast POV man label") + _expect("Woman A:" in cast_route["cast_descriptor_text"], "Subject route lost visible woman descriptor") + _expect("Man A:" not in cast_route["cast_descriptor_text"], "Subject route should not describe POV man as visible cast") + + def smoke_generation_profile_config_policy() -> None: _expect( pb.GENERATION_PROFILE_PRESETS is generation_profile_config.GENERATION_PROFILE_PRESETS, @@ -4313,6 +4392,7 @@ SMOKE_CASES: list[tuple[str, Callable[[], None]]] = [ ("row_generation_policy", smoke_row_generation_policy), ("category_extensions_policy", smoke_category_extensions_policy), ("category_cast_config_policy", smoke_category_cast_config_policy), + ("row_subject_route_policy", smoke_row_subject_route_policy), ("generation_profile_config_policy", smoke_generation_profile_config_policy), ("filter_config_policy", smoke_filter_config_policy), ("character_config_policy", smoke_character_config_policy),