diff --git a/docs/prompt-architecture-improvement-plan.md b/docs/prompt-architecture-improvement-plan.md index 26bcfa4..9cdb6fa 100644 --- a/docs/prompt-architecture-improvement-plan.md +++ b/docs/prompt-architecture-improvement-plan.md @@ -340,6 +340,10 @@ Already isolated: `KreaConfiguredCastDependencies`, and `KreaConfiguredCastPrompt`; `krea_formatter.py` keeps configured-cast detection and compatibility wrapper helpers. +- `krea_normal_formatter.py` owns normal metadata single/couple/generic Krea + prose assembly behind `KreaNormalRowRequest`, `KreaNormalRowDependencies`, + and `KreaNormalRowPrompt`; `krea_formatter.py` keeps common row-field + extraction and route selection. - `krea_pair_formatter.py` owns Insta/OF pair soft/hard Krea prose assembly behind `KreaPairFormatRequest`, `KreaPairFormatDependencies`, and `KreaPairPrompts`; `krea_formatter.py` keeps the `_insta_pair_to_krea` diff --git a/docs/prompt-pool-routing-map.md b/docs/prompt-pool-routing-map.md index d86fbaf..0ae44c6 100644 --- a/docs/prompt-pool-routing-map.md +++ b/docs/prompt-pool-routing-map.md @@ -653,7 +653,9 @@ Important POV rule: - Normal configured-cast metadata row: `krea_configured_cast_formatter.format_configured_cast_result` through the `_normal_row_to_krea` compatibility wrapper. -- Other normal metadata rows: `_normal_row_to_krea`. +- Other normal metadata rows: + `krea_normal_formatter.format_normal_row_result` through the + `_normal_row_to_krea` compatibility wrapper. - Plain text fallback: `_fallback_text_to_krea`. Key Krea2 ownership: @@ -671,6 +673,8 @@ Key Krea2 ownership: `krea_pair_formatter.format_insta_pair_result`. - Normal configured-cast Krea prose assembly: `krea_configured_cast_formatter.format_configured_cast_result`. +- Normal single/couple/generic Krea prose assembly: + `krea_normal_formatter.format_normal_row_result`. - Shared POV labels/filtering/composition cleanup: `pov_policy.py`. - Krea POV camera support: `krea_pov.py`. - Detail clause splitting and density limiting: `krea_detail.py`. @@ -682,8 +686,8 @@ Krea2 field consumption: | Branch | Reads most from | Key functions | | --- | --- | --- | -| Normal single row | `subject_type`, `item`, `pose`, `scene_text`, `expression`, `composition`, `camera_*`, style fields | `_normal_row_to_krea` | -| Normal configured cast/hardcore row | `cast_descriptor_text`, `women_count`, `men_count`, `source_role_graph`, `role_graph`, `item`, `item_axis_values`, `source_composition`, `pov_character_labels` | `_normal_row_to_krea`, `krea_actions.hardcore_action_sentence`, `krea_pov_actions.pov_action_phrase` | +| Normal single/couple/generic row | `subject_type`, `item`, `pose`, `scene_text`, `expression`, `composition`, `camera_*`, style fields | `krea_normal_formatter.format_normal_row_result` | +| Normal configured cast/hardcore row | `cast_descriptor_text`, `women_count`, `men_count`, `source_role_graph`, `role_graph`, `item`, `item_axis_values`, `source_composition`, `pov_character_labels` | `krea_configured_cast_formatter.format_configured_cast_result`, `krea_actions.hardcore_action_sentence`, `krea_pov_actions.pov_action_phrase` | | Insta/OF pair softcore | `shared_descriptor`, `softcore_row`, `softcore_partner_styling`, options, soft camera fields | `krea_pair_formatter.format_insta_pair_result` | | Insta/OF pair hardcore | `hardcore_row`, `shared_cast_descriptors`, `hardcore_clothing_state`, `hardcore_detail_density`, hard camera fields, POV labels | `krea_pair_formatter.format_insta_pair_result`, `krea_actions.hardcore_action_sentence`, `krea_pov_actions.pov_action_phrase`, `krea_clothing.natural_clothing_state` | | Plain text fallback | `source_text` only | `_fallback_text_to_krea` | diff --git a/krea_formatter.py b/krea_formatter.py index 3cf5fbd..a225756 100644 --- a/krea_formatter.py +++ b/krea_formatter.py @@ -12,6 +12,7 @@ try: normalize_hardcore_detail_density as _normalize_hardcore_detail_density, ) from . import krea_configured_cast_formatter + from . import krea_normal_formatter from . import krea_pair_formatter from .hardcore_text_cleanup import ( sanitize_hardcore_axis_values as _sanitize_hardcore_axis_values, @@ -45,6 +46,7 @@ except ImportError: # Allows local smoke tests with `python -c`. normalize_hardcore_detail_density as _normalize_hardcore_detail_density, ) import krea_configured_cast_formatter + import krea_normal_formatter import krea_pair_formatter from hardcore_text_cleanup import ( sanitize_hardcore_axis_values as _sanitize_hardcore_axis_values, @@ -397,16 +399,51 @@ def _style_phrase(row: dict[str, Any], style_mode: str) -> str: return style or suffix -def _couple_clothing_phrase(item: str) -> str: - item = _clean(item) - lower = item.lower() - partner_text = re.sub(r"\bPartner ([AB]) wears\b", r"Partner \1 wearing", item) - partner_text = re.sub(r"\bPartner ([AB]) has\b", r"Partner \1 with", partner_text) - if lower.startswith("partner a "): - return f"The outfits show {partner_text}" - if lower.startswith(("two ", "paired ", "coordinated ")): - return f"The outfits are {partner_text}" - return f"The couple wears {item}" +def _krea_normal_row_dependencies() -> krea_normal_formatter.KreaNormalRowDependencies: + return krea_normal_formatter.KreaNormalRowDependencies( + clean=_clean, + row_value=_row_value, + age_subject=_age_subject, + age_detail_phrase=_age_detail_phrase, + appearance_phrase=_appearance_phrase, + with_indefinite_article=_with_indefinite_article, + paragraph=_paragraph, + ) + + +def _krea_normal_row_request_from_row( + row: dict[str, Any], + detail_level: str, + style_mode: str, +) -> krea_normal_formatter.KreaNormalRowRequest: + subject_type = _clean(row.get("subject_type")) + primary = _clean(row.get("primary_subject")) + item = _row_value(row, "item", ("Sexual pose", "Erotic outfit", "Clothing")) or _clean(row.get("custom_item")) + item = re.sub(r",?\s*(fashion editorial|resort) styling$", "", item, flags=re.IGNORECASE) + scene = _row_value(row, "scene_text", ("Setting", "Scene")) or _clean(row.get("scene")) + pose = _row_value(row, "pose", ("Sexual pose", "Pose")) + expression = "" + if not _expression_disabled(row): + expression = _row_value(row, "character_expression_text") or _row_value(row, "expression", ("Facial expressions", "Facial expression")) + composition = re.sub(r"^vertical\s+", "", _row_value(row, "composition", ("Composition",)), flags=re.IGNORECASE) + camera = _camera_phrase(row) + camera_scene = _camera_scene_phrase(row) + style = _style_phrase(row, style_mode) + return krea_normal_formatter.KreaNormalRowRequest( + row=row, + detail_level=detail_level, + style_mode=style_mode, + subject_type=subject_type, + primary=primary, + item=item, + scene=scene, + pose=pose, + expression=expression, + composition=composition, + camera=camera, + camera_scene=camera_scene, + style=style, + ) def _krea_configured_cast_dependencies() -> krea_configured_cast_formatter.KreaConfiguredCastDependencies: @@ -508,70 +545,10 @@ def _normal_row_to_krea(row: dict[str, Any], detail_level: str, style_mode: str) _krea_configured_cast_request_from_row(row, detail_level, style_mode), _krea_configured_cast_dependencies(), ) - - primary = _clean(row.get("primary_subject")) - item = _row_value(row, "item", ("Sexual pose", "Erotic outfit", "Clothing")) or _clean(row.get("custom_item")) - item = re.sub(r",?\s*(fashion editorial|resort) styling$", "", item, flags=re.IGNORECASE) - scene = _row_value(row, "scene_text", ("Setting", "Scene")) or _clean(row.get("scene")) - pose = _row_value(row, "pose", ("Sexual pose", "Pose")) - expression = "" - if not _expression_disabled(row): - expression = _row_value(row, "character_expression_text") or _row_value(row, "expression", ("Facial expressions", "Facial expression")) - composition = re.sub(r"^vertical\s+", "", _row_value(row, "composition", ("Composition",)), flags=re.IGNORECASE) - camera = _camera_phrase(row) - camera_scene = _camera_scene_phrase(row) - style = _style_phrase(row, style_mode) - - if primary in ("woman", "man") or subject_type in ("woman", "man", "single_any"): - subject = _age_subject(row, "adult woman") - appearance = _appearance_phrase(row) - parts = [ - _with_indefinite_article(subject), - f"with {appearance}" if appearance else "", - f"wearing {item}" if item else "", - f"{pose}" if pose else "", - f"with {expression}" if expression else "", - f"in {scene}" if scene else "", - camera_scene, - f"framed as {composition}" if composition else "", - camera, - style if detail_level != "concise" else "", - ] - return _paragraph([", ".join(part for part in parts[:6] if part), *parts[6:]]), "metadata(single)" - - if subject_type == "couple" or primary in ("two women", "two men", "a woman and a man"): - subject = _clean(row.get("subject_phrase") or primary or "adult couple") - if subject == "woman and man": - subject = "a woman and a man" - ages = _age_detail_phrase(_row_value(row, "age", ("Ages",)) or row.get("age_band")) - body = _row_value(row, "body", ("Body types",)) or _clean(row.get("body_type")) - parts = [ - f"An adult couple: {subject}, all visibly adult", - f"Age detail: {ages}" if ages else "", - f"Body types: {body}" if body else "", - _couple_clothing_phrase(item) if item else "", - f"The pose is {pose}" if pose else "", - f"The setting is {scene}" if scene else "", - camera_scene, - f"Facial expressions are {expression}" if expression else "", - f"The image is framed as {composition}" if composition else "", - camera, - style if detail_level != "concise" else "", - ] - return _paragraph(parts), "metadata(couple)" - - subject = _age_subject(row, primary or "adult scene") - parts = [ - f"{subject}", - f"featuring {item}" if item else "", - f"in {scene}" if scene else "", - camera_scene, - f"with {expression}" if expression else "", - f"framed as {composition}" if composition else "", - camera, - style if detail_level != "concise" else "", - ] - return _paragraph(parts), "metadata(generic)" + return krea_normal_formatter.format_normal_row( + _krea_normal_row_request_from_row(row, detail_level, style_mode), + _krea_normal_row_dependencies(), + ) def _krea_pair_format_dependencies() -> krea_pair_formatter.KreaPairFormatDependencies: diff --git a/krea_normal_formatter.py b/krea_normal_formatter.py new file mode 100644 index 0000000..2c5625e --- /dev/null +++ b/krea_normal_formatter.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import Any, Callable + + +@dataclass(frozen=True) +class KreaNormalRowRequest: + row: dict[str, Any] + detail_level: str + style_mode: str + subject_type: str + primary: str + item: str + scene: str + pose: str + expression: str + composition: str + camera: str + camera_scene: str + style: str + + +@dataclass(frozen=True) +class KreaNormalRowPrompt: + prompt: str + method: str + + def as_tuple(self) -> tuple[str, str]: + return self.prompt, self.method + + +@dataclass(frozen=True) +class KreaNormalRowDependencies: + clean: Callable[[Any], str] + row_value: Callable[[dict[str, Any], str, tuple[str, ...]], str] + age_subject: Callable[[dict[str, Any], str], str] + age_detail_phrase: Callable[[Any], str] + appearance_phrase: Callable[[dict[str, Any]], str] + with_indefinite_article: Callable[[str], str] + paragraph: Callable[[list[str]], str] + + +def _couple_clothing_phrase(item: str, clean: Callable[[Any], str]) -> str: + item = clean(item) + lower = item.lower() + partner_text = re.sub(r"\bPartner ([AB]) wears\b", r"Partner \1 wearing", item) + partner_text = re.sub(r"\bPartner ([AB]) has\b", r"Partner \1 with", partner_text) + if lower.startswith("partner a "): + return f"The outfits show {partner_text}" + if lower.startswith(("two ", "paired ", "coordinated ")): + return f"The outfits are {partner_text}" + return f"The couple wears {item}" + + +def format_normal_row_result( + request: KreaNormalRowRequest, + deps: KreaNormalRowDependencies, +) -> KreaNormalRowPrompt: + row = request.row + subject_type = request.subject_type + primary = request.primary + item = request.item + scene = request.scene + pose = request.pose + expression = request.expression + composition = request.composition + camera = request.camera + camera_scene = request.camera_scene + style = request.style + detail_level = request.detail_level + + if primary in ("woman", "man") or subject_type in ("woman", "man", "single_any"): + subject = deps.age_subject(row, "adult woman") + appearance = deps.appearance_phrase(row) + parts = [ + deps.with_indefinite_article(subject), + f"with {appearance}" if appearance else "", + f"wearing {item}" if item else "", + f"{pose}" if pose else "", + f"with {expression}" if expression else "", + f"in {scene}" if scene else "", + camera_scene, + f"framed as {composition}" if composition else "", + camera, + style if detail_level != "concise" else "", + ] + return KreaNormalRowPrompt( + deps.paragraph([", ".join(part for part in parts[:6] if part), *parts[6:]]), + "metadata(single)", + ) + + if subject_type == "couple" or primary in ("two women", "two men", "a woman and a man"): + subject = deps.clean(row.get("subject_phrase") or primary or "adult couple") + if subject == "woman and man": + subject = "a woman and a man" + ages = deps.age_detail_phrase(deps.row_value(row, "age", ("Ages",)) or row.get("age_band")) + body = deps.row_value(row, "body", ("Body types",)) or deps.clean(row.get("body_type")) + parts = [ + f"An adult couple: {subject}, all visibly adult", + f"Age detail: {ages}" if ages else "", + f"Body types: {body}" if body else "", + _couple_clothing_phrase(item, deps.clean) if item else "", + f"The pose is {pose}" if pose else "", + f"The setting is {scene}" if scene else "", + camera_scene, + f"Facial expressions are {expression}" if expression else "", + f"The image is framed as {composition}" if composition else "", + camera, + style if detail_level != "concise" else "", + ] + return KreaNormalRowPrompt(deps.paragraph(parts), "metadata(couple)") + + subject = deps.age_subject(row, primary or "adult scene") + parts = [ + f"{subject}", + f"featuring {item}" if item else "", + f"in {scene}" if scene else "", + camera_scene, + f"with {expression}" if expression else "", + f"framed as {composition}" if composition else "", + camera, + style if detail_level != "concise" else "", + ] + return KreaNormalRowPrompt(deps.paragraph(parts), "metadata(generic)") + + +def format_normal_row( + request: KreaNormalRowRequest, + deps: KreaNormalRowDependencies, +) -> tuple[str, str]: + return format_normal_row_result(request, deps).as_tuple() diff --git a/tools/prompt_smoke.py b/tools/prompt_smoke.py index 8b196a3..b0e8b9e 100644 --- a/tools/prompt_smoke.py +++ b/tools/prompt_smoke.py @@ -44,6 +44,7 @@ import index_switch_policy # noqa: E402 import krea_cast # noqa: E402 import krea_configured_cast_formatter # noqa: E402 import krea_formatter # noqa: E402 +import krea_normal_formatter # noqa: E402 import krea_pair_formatter # noqa: E402 import location_config # noqa: E402 import loop_nodes # noqa: E402 @@ -184,6 +185,20 @@ def _expect_formatter_outputs(row: dict[str, Any], name: str, *, target: str = " _expect_trigger_once(f"{name}.caption", caption, Trigger) +def _expect_krea_normal_route_parity(row: dict[str, Any], name: str, method: str) -> None: + typed_route = krea_normal_formatter.format_normal_row_result( + krea_formatter._krea_normal_row_request_from_row(row, "balanced", "preserve"), + krea_formatter._krea_normal_row_dependencies(), + ) + legacy_route = krea_formatter._normal_row_to_krea(row, "balanced", "preserve") + _expect( + typed_route.as_tuple() == legacy_route, + f"{name} typed Krea normal formatter route should match legacy wrapper output", + ) + _expect(typed_route.method == method, f"{name} typed Krea normal formatter method changed") + _expect_text(f"{name}.typed_krea_prompt", typed_route.prompt, 20) + + def _character_cast(*, pov_man: bool = False) -> str: cast = pb.build_character_slot_json( subject_type="woman", @@ -583,6 +598,54 @@ def smoke_config_route_location_theme() -> None: _expect_formatter_outputs(row, "config_route_location_theme", target="single") +def smoke_krea_normal_row_routes() -> None: + single = { + "subject_type": "woman", + "primary_subject": "woman", + "age_band": "25-year-old adult", + "body_phrase": "slim figure", + "skin": "fair skin", + "hair": "long blonde hair", + "eyes": "blue eyes", + "item": "silk dress", + "pose": "standing beside a window", + "scene_text": "quiet studio with warm daylight", + "expression": "soft smile", + "composition": "vertical centered portrait", + "camera_directive": "Camera: eye-level medium shot", + "style": "realistic creator-shot photography", + } + _expect_krea_normal_route_parity(single, "krea_normal_single", "metadata(single)") + + couple = { + "subject_type": "couple", + "primary_subject": "a woman and a man", + "subject_phrase": "woman and man", + "age": "25-year-old adult and 40-year-old adult", + "body": "slim and average builds", + "item": "Partner A wears black dress; Partner B wears dark shirt", + "pose": "standing close together", + "scene_text": "private lounge with soft lamps", + "expression": "shared confident gaze", + "composition": "two-person editorial frame", + "camera_directive": "Camera: front view, medium shot", + "style": "realistic social photo", + } + _expect_krea_normal_route_parity(couple, "krea_normal_couple", "metadata(couple)") + + generic = { + "subject_type": "location", + "primary_subject": "adult editorial scene", + "item": "polished lounge styling", + "scene_text": "hotel hallway with warm wall sconces", + "expression": "quiet atmosphere", + "composition": "wide establishing frame", + "camera_directive": "Camera: wide shot", + "style": "clean photographic realism", + } + _expect_krea_normal_route_parity(generic, "krea_normal_generic", "metadata(generic)") + + def smoke_location_config_policy() -> None: _expect(pb.LOCATION_POOL_PRESETS is location_config.LOCATION_POOL_PRESETS, "Prompt builder location presets are not delegated") _expect(pb.COMPOSITION_POOL_PRESETS is location_config.COMPOSITION_POOL_PRESETS, "Prompt builder composition presets are not delegated") @@ -5004,6 +5067,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), + ("krea_normal_row_routes", smoke_krea_normal_row_routes), ("location_config_policy", smoke_location_config_policy), ("row_location_policy", smoke_row_location_policy), ("row_expression_policy", smoke_row_expression_policy),