diff --git a/cast_context.py b/cast_context.py index b9c28e5..755a4f0 100644 --- a/cast_context.py +++ b/cast_context.py @@ -2,6 +2,11 @@ from __future__ import annotations from typing import Any, Callable +try: + from . import character_config as character_policy +except ImportError: # Allows local smoke tests with top-level imports. + import character_config as character_policy + Choose = Callable[[Any, list[tuple[str, str, str]]], tuple[str, str, str]] @@ -37,6 +42,30 @@ def cast_summary_phrase(women_count: int, men_count: int) -> str: return f"{women_count} {women_label}, {men_count} {men_label}, {person_count} total adults" +def explicit_character_slot_label(slot: dict[str, Any]) -> str: + label = str(slot.get("label") or "").strip().upper() + if label in character_policy.CHARACTER_LABEL_CHOICES and label != "AUTO_CHAIN": + return label + return "" + + +def character_slot_label_map(slots: list[dict[str, Any]]) -> dict[str, dict[str, Any]]: + label_map: dict[str, dict[str, Any]] = {} + letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + for subject_type, prefix in (("woman", "Woman"), ("man", "Man")): + subject_slots = [slot for slot in slots if slot.get("subject_type") == subject_type] + auto_slots = [slot for slot in subject_slots if not explicit_character_slot_label(slot)] + for index, slot in enumerate(reversed(auto_slots)): + if index >= len(letters): + break + label_map[f"{prefix} {letters[index]}"] = slot + for slot in subject_slots: + explicit = explicit_character_slot_label(slot) + if explicit: + label_map[f"{prefix} {explicit}"] = slot + return label_map + + def configured_cast_context(women_count: int, men_count: int) -> dict[str, str]: women_count = max(0, int(women_count)) men_count = max(0, int(men_count)) diff --git a/docs/prompt-architecture-improvement-plan.md b/docs/prompt-architecture-improvement-plan.md index f642b60..dcd2393 100644 --- a/docs/prompt-architecture-improvement-plan.md +++ b/docs/prompt-architecture-improvement-plan.md @@ -130,9 +130,10 @@ Already isolated: parsers live in `category_cast_config.py`; `prompt_builder.py` keeps public delegate wrappers for existing nodes and tests. - generation-time cast count phrases, configured-cast context metadata, - scene-kind labels, cast-summary wording, and couple count normalization live - in `cast_context.py`; `prompt_builder.py` keeps delegate wrappers where - existing generation paths still call the old helper names. + character-slot label assignment, scene-kind labels, cast-summary wording, and + couple count normalization live in `cast_context.py`; `prompt_builder.py` + keeps delegate wrappers where existing generation paths still call the old + helper names. - 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 2cedb31..1ef57d0 100644 --- a/docs/prompt-pool-routing-map.md +++ b/docs/prompt-pool-routing-map.md @@ -70,7 +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. | | `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, cast-summary wording, scene-kind labels, and couple count normalization. | +| `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. | | `character_config.py` | Character choice lists, descriptor detail/presence/slot-seed normalization, characteristic-list JSON builders/parsers, eye labels, hair config builders/parsers, and hair phrase helpers. | | `character_profile.py` | Character manual-detail config, profile name/path policy, profile JSON normalization, descriptor assembly, save/load/rename/delete operations, fallback profile loading, and context override application. | @@ -390,7 +390,8 @@ Important behavior: Edit targets: - Appearance field generation: `_context_from_character_slot`, - `_character_context_for_label`; pair cast descriptor entry assembly: + `_character_context_for_label`; character-slot label assignment: + `cast_context.character_slot_label_map`; pair cast descriptor entry assembly: `pair_cast.cast_descriptor_entries`. - Profile save/load: `SxCPCharacterProfileSave`, `SxCPCharacterProfileLoad`, profile policy in `character_profile.py`, and diff --git a/prompt_builder.py b/prompt_builder.py index 320acc0..62beab0 100644 --- a/prompt_builder.py +++ b/prompt_builder.py @@ -2432,27 +2432,11 @@ def build_character_slot_json( def _slot_explicit_label(slot: dict[str, Any]) -> str: - label = str(slot.get("label") or "").strip().upper() - if label in CHARACTER_LABEL_CHOICES and label != "AUTO_CHAIN": - return label - return "" + return cast_context_policy.explicit_character_slot_label(slot) def _character_slot_label_map(slots: list[dict[str, Any]]) -> dict[str, dict[str, Any]]: - label_map: dict[str, dict[str, Any]] = {} - letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" - for subject_type, prefix in (("woman", "Woman"), ("man", "Man")): - subject_slots = [slot for slot in slots if slot.get("subject_type") == subject_type] - auto_slots = [slot for slot in subject_slots if not _slot_explicit_label(slot)] - for index, slot in enumerate(reversed(auto_slots)): - if index >= len(letters): - break - label_map[f"{prefix} {letters[index]}"] = slot - for slot in subject_slots: - explicit = _slot_explicit_label(slot) - if explicit: - label_map[f"{prefix} {explicit}"] = slot - return label_map + return cast_context_policy.character_slot_label_map(slots) def _pov_character_labels( diff --git a/tools/prompt_smoke.py b/tools/prompt_smoke.py index 21a0663..03ecba8 100644 --- a/tools/prompt_smoke.py +++ b/tools/prompt_smoke.py @@ -682,6 +682,25 @@ def smoke_category_cast_config_policy() -> None: == ("two women", "two women", "close affectionate couple pose", 2, 0), "Couple type count override for two women changed", ) + label_slots = [ + {"subject_type": "woman", "label": "auto_chain", "name": "older auto"}, + {"subject_type": "woman", "label": "auto_chain", "name": "newer auto"}, + {"subject_type": "man", "label": "B", "name": "explicit man"}, + ] + label_map = cast_context.character_slot_label_map(label_slots) + _expect( + label_map.get("Woman A", {}).get("name") == "newer auto" + and label_map.get("Woman B", {}).get("name") == "older auto", + "Character slot auto-chain label order changed", + ) + _expect( + label_map.get("Man B", {}).get("name") == "explicit man", + "Character slot explicit label mapping changed", + ) + _expect( + pb._character_slot_label_map(label_slots) == label_map, + "Prompt builder character slot label map should delegate to cast_context", + ) category_config = json.loads(pb.build_category_config_json("hardcore_pose", "Foreplay and teasing")) _expect(category_config.get("category") == "Hardcore sexual poses", "Category config lost hardcore category mapping")