diff --git a/docs/prompt-architecture-improvement-plan.md b/docs/prompt-architecture-improvement-plan.md index 9f678dd..11d1d1d 100644 --- a/docs/prompt-architecture-improvement-plan.md +++ b/docs/prompt-architecture-improvement-plan.md @@ -131,6 +131,9 @@ Already isolated: - row item selection, weighted item/pair choice, item-template axis filling, and oral/outercourse axis compatibility filters live in `row_item.py`; `prompt_builder.py` keeps public delegate wrappers. +- row prompt/caption template selection, safe formatting, default prompt + templates, configured-cast descriptor insertion, and POV directive insertion + live in `row_rendering.py`; `prompt_builder.py` keeps compatibility aliases. - row action/position route metadata resolution, template metadata precedence, inferred position-key merging, and source action-family fallback live in `row_route_metadata.py`; `prompt_builder.py` keeps a public delegate wrapper. diff --git a/docs/prompt-pool-routing-map.md b/docs/prompt-pool-routing-map.md index e4cb2bd..cc3e309 100644 --- a/docs/prompt-pool-routing-map.md +++ b/docs/prompt-pool-routing-map.md @@ -71,6 +71,7 @@ Core helper ownership: | `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. | +| `row_rendering.py` | Row prompt/caption template selection, safe formatting, default prompt templates, configured-cast descriptor insertion, and POV directive insertion. | | `row_route_metadata.py` | Row action/position route metadata resolution, template metadata precedence, inferred position-key merging, and source action-family fallback. | | `row_generation.py` | Built-in legacy row generation, auto-weighted/auto-full selection, row mode randomization, ratio clamps, and expression-intensity randomization. | | `category_cast_config.py` | Category preset and cast preset schemas, category/cast config JSON builders, choice lists, and config parsers used by route nodes. | @@ -245,7 +246,8 @@ Important JSON keys: - `expression_pool` / `expression_pools` or direct `expressions`: expression road. - `composition_pool` / `composition_pools` or direct `compositions`: framing road. - `poses`: category-specific pose fallback. -- `prompt_template` / `caption_template`: final prompt assembly for that category. +- `prompt_template` / `caption_template`: final row prompt/caption assembly, + selected and formatted by `row_rendering.py`. - `inherit_scenes`, `inherit_expressions`, `inherit_compositions`: stop or allow inheritance from category/subcategory/item levels. - `pool_extensions`: patch legacy pools from JSON through `category_extensions.py`. diff --git a/prompt_builder.py b/prompt_builder.py index 36d0ff9..63a33e9 100644 --- a/prompt_builder.py +++ b/prompt_builder.py @@ -1,9 +1,7 @@ from __future__ import annotations import random -import re from pathlib import Path -from string import Formatter from typing import Any try: @@ -42,6 +40,7 @@ try: from . import row_item as row_item_policy from . import row_location as row_location_policy 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 seed_config as seed_policy from . import subject_context as subject_context_policy @@ -86,6 +85,7 @@ except ImportError: # Allows local smoke tests with `python -c`. import row_item as row_item_policy import row_location as row_location_policy import row_pools as row_pool_policy + import row_rendering as row_rendering_policy import row_route_metadata as row_route_policy import seed_config as seed_policy import subject_context as subject_context_policy @@ -146,34 +146,11 @@ def _hardcore_position_keys(*parts: Any, axis_values: dict[str, Any] | None = No CAMERA_ORBIT_FRAMING_CHOICES = camera_policy.CAMERA_ORBIT_FRAMING_CHOICES CAMERA_ORBIT_FOCUS_CHOICES = camera_policy.CAMERA_ORBIT_FOCUS_CHOICES -GENERIC_POSITIVE_SUFFIX = ( - "Use crisp clean comic linework, detailed hatching, soft blended shading, " - "pastel skin tones, muted blues and pinks, warm sensual lighting, and tactile textured paper." -) - -SINGLE_TEMPLATE = ( - "A {subject}: {style}, {age}, {body_phrase}, {skin}, {hair}, {eyes}. " - "{item_label}: {item}. Scene: {scene}. Pose: {pose}. Facial expression: {expression}. " - "Composition: {composition_prompt}. {positive_suffix} Avoid: {negative_prompt}." -) - -COUPLE_TEMPLATE = ( - "{subject_phrase}: {style}. Ages: {age}. Body types: {body}. {item_label}: {item}. " - "Scene: {scene}. Pose: {pose}. Facial expressions: {expression}. " - "Composition: {composition_prompt}. {positive_suffix} Avoid: {negative_prompt}." -) - -GROUP_TEMPLATE = ( - "{subject_phrase}: {style}, ages {age}, diverse adult body types. {item_label}: {item}. " - "Scene: {scene}. Facial expressions: {expression}. Composition: {composition_prompt}. " - "{positive_suffix} Avoid: {negative_prompt}." -) - -LAYOUT_TEMPLATE = ( - "{item}: {style}, adults only, clean designed composition. Scene: {scene}. " - "Facial expression: {expression}. Composition: {composition}. {positive_suffix} " - "Avoid: {negative_prompt}. Use no readable text unless the layout naturally needs small decorative placeholder marks." -) +GENERIC_POSITIVE_SUFFIX = row_rendering_policy.GENERIC_POSITIVE_SUFFIX +SINGLE_TEMPLATE = row_rendering_policy.SINGLE_TEMPLATE +COUPLE_TEMPLATE = row_rendering_policy.COUPLE_TEMPLATE +GROUP_TEMPLATE = row_rendering_policy.GROUP_TEMPLATE +LAYOUT_TEMPLATE = row_rendering_policy.LAYOUT_TEMPLATE CAMERA_MODE_PROMPTS = camera_policy.CAMERA_MODE_PROMPTS CAMERA_COMPACT_LABELS = camera_policy.CAMERA_COMPACT_LABELS @@ -186,9 +163,7 @@ CAMERA_PHONE_PROMPTS = camera_policy.CAMERA_PHONE_PROMPTS CAMERA_PRIORITY_PROMPTS = camera_policy.CAMERA_PRIORITY_PROMPTS -class SafeFormatDict(dict): - def __missing__(self, key: str) -> str: - return "{" + key + "}" +SafeFormatDict = row_rendering_policy.SafeFormatDict def _slug(value: str) -> str: @@ -812,11 +787,7 @@ def _is_pose_content_category(category: dict[str, Any], subcategory: dict[str, A def _format(template: str, context: dict[str, Any]) -> str: - fields = {key for _, key, _, _ in Formatter().parse(template) if key} - safe_context = SafeFormatDict({key: str(value) for key, value in context.items()}) - for field in fields: - safe_context.setdefault(field, "{" + field + "}") - return template.format_map(safe_context) + return row_rendering_policy.format_template(template, context) def _clean_prompt_punctuation(text: str) -> str: @@ -2294,35 +2265,17 @@ def _build_custom_row( } ) - if isinstance(item, dict) and "prompt_template" in item: - template = str(item["prompt_template"]) - else: - template = str(subcategory.get("prompt_template") or category.get("prompt_template") or "") - if not template: - if subject_type in ("woman", "man"): - template = SINGLE_TEMPLATE - elif subject_type == "couple": - template = COUPLE_TEMPLATE - elif subject_type == "group": - template = GROUP_TEMPLATE - else: - template = LAYOUT_TEMPLATE - - caption_template = str( - (item.get("caption_template") if isinstance(item, dict) else None) - or subcategory.get("caption_template") - or category.get("caption_template") - or "{trigger}, {subject_phrase}, {age}, {item}, {scene}, {composition}, coloured pencil comic illustration" + rendered = row_rendering_policy.render_prompt_caption( + item=item, + subcategory=subcategory, + category=category, + subject_type=subject_type, + context=context, + cast_descriptor_text=cast_descriptor_text, + pov_prompt_directive=_pov_prompt_directive(pov_character_labels) if pov_character_labels else "", ) - - prompt = _format(template, context) - if subject_type == "configured_cast" and cast_descriptor_text and "{cast_descriptors}" not in template: - prompt = _insert_positive_directive(prompt, f"Characters: {cast_descriptor_text}.") - if subject_type == "configured_cast" and pov_character_labels: - prompt = _insert_positive_directive(prompt, _pov_prompt_directive(pov_character_labels)) - caption = _format(caption_template, context) - if subject_type == "configured_cast" and cast_descriptor_text and "{cast_descriptors}" not in caption_template: - caption = f"{caption.rstrip()}, {cast_descriptor_text}" + prompt = rendered["prompt"] + caption = rendered["caption"] batch = max(1, ((row_number - 1) // g.BATCH_SIZE) + 1) index = start_index + row_number - 1 row = g.row_base(index, batch, context["subject"], context["age"], context["body"], scene_slug, composition) diff --git a/row_rendering.py b/row_rendering.py new file mode 100644 index 0000000..1f13a00 --- /dev/null +++ b/row_rendering.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +from string import Formatter +from typing import Any + +try: + from . import row_camera as row_camera_policy +except ImportError: # Allows local smoke tests from the repository root. + import row_camera as row_camera_policy + + +GENERIC_POSITIVE_SUFFIX = ( + "Use crisp clean comic linework, detailed hatching, soft blended shading, " + "pastel skin tones, muted blues and pinks, warm sensual lighting, and tactile textured paper." +) + +SINGLE_TEMPLATE = ( + "A {subject}: {style}, {age}, {body_phrase}, {skin}, {hair}, {eyes}. " + "{item_label}: {item}. Scene: {scene}. Pose: {pose}. Facial expression: {expression}. " + "Composition: {composition_prompt}. {positive_suffix} Avoid: {negative_prompt}." +) + +COUPLE_TEMPLATE = ( + "{subject_phrase}: {style}. Ages: {age}. Body types: {body}. {item_label}: {item}. " + "Scene: {scene}. Pose: {pose}. Facial expressions: {expression}. " + "Composition: {composition_prompt}. {positive_suffix} Avoid: {negative_prompt}." +) + +GROUP_TEMPLATE = ( + "{subject_phrase}: {style}, ages {age}, diverse adult body types. {item_label}: {item}. " + "Scene: {scene}. Facial expressions: {expression}. Composition: {composition_prompt}. " + "{positive_suffix} Avoid: {negative_prompt}." +) + +LAYOUT_TEMPLATE = ( + "{item}: {style}, adults only, clean designed composition. Scene: {scene}. " + "Facial expression: {expression}. Composition: {composition}. {positive_suffix} " + "Avoid: {negative_prompt}. Use no readable text unless the layout naturally needs small decorative placeholder marks." +) + +DEFAULT_CAPTION_TEMPLATE = ( + "{trigger}, {subject_phrase}, {age}, {item}, {scene}, {composition}, coloured pencil comic illustration" +) + + +class SafeFormatDict(dict): + def __missing__(self, key: str) -> str: + return "{" + key + "}" + + +def format_template(template: str, context: dict[str, Any]) -> str: + fields = {key for _, key, _, _ in Formatter().parse(template) if key} + safe_context = SafeFormatDict({key: str(value) for key, value in context.items()}) + for field in fields: + safe_context.setdefault(field, "{" + field + "}") + return template.format_map(safe_context) + + +def default_prompt_template(subject_type: str) -> str: + if subject_type in ("woman", "man"): + return SINGLE_TEMPLATE + if subject_type == "couple": + return COUPLE_TEMPLATE + if subject_type == "group": + return GROUP_TEMPLATE + return LAYOUT_TEMPLATE + + +def prompt_template_for(item: Any, subcategory: dict[str, Any], category: dict[str, Any], subject_type: str) -> str: + if isinstance(item, dict) and "prompt_template" in item: + return str(item["prompt_template"]) + template = str(subcategory.get("prompt_template") or category.get("prompt_template") or "") + return template or default_prompt_template(subject_type) + + +def caption_template_for(item: Any, subcategory: dict[str, Any], category: dict[str, Any]) -> str: + return str( + (item.get("caption_template") if isinstance(item, dict) else None) + or subcategory.get("caption_template") + or category.get("caption_template") + or DEFAULT_CAPTION_TEMPLATE + ) + + +def render_prompt_caption( + *, + item: Any, + subcategory: dict[str, Any], + category: dict[str, Any], + subject_type: str, + context: dict[str, Any], + cast_descriptor_text: str = "", + pov_prompt_directive: str = "", +) -> dict[str, str]: + prompt_template = prompt_template_for(item, subcategory, category, subject_type) + caption_template = caption_template_for(item, subcategory, category) + + prompt = format_template(prompt_template, context) + if subject_type == "configured_cast" and cast_descriptor_text and "{cast_descriptors}" not in prompt_template: + prompt = row_camera_policy.insert_positive_directive(prompt, f"Characters: {cast_descriptor_text}.") + if subject_type == "configured_cast" and pov_prompt_directive: + prompt = row_camera_policy.insert_positive_directive(prompt, pov_prompt_directive) + + caption = format_template(caption_template, context) + if subject_type == "configured_cast" and cast_descriptor_text and "{cast_descriptors}" not in caption_template: + caption = f"{caption.rstrip()}, {cast_descriptor_text}" + + return { + "prompt": prompt, + "caption": caption, + "prompt_template": prompt_template, + "caption_template": caption_template, + } diff --git a/tools/prompt_smoke.py b/tools/prompt_smoke.py index 3773719..813c7f0 100644 --- a/tools/prompt_smoke.py +++ b/tools/prompt_smoke.py @@ -57,6 +57,7 @@ import row_generation # noqa: E402 import row_item # noqa: E402 import row_location # noqa: E402 import row_pools # noqa: E402 +import row_rendering # noqa: E402 import row_route_metadata # noqa: E402 import server_routes # noqa: E402 import sdxl_formatter # noqa: E402 @@ -1402,6 +1403,73 @@ def smoke_row_normalization_policy() -> None: _expect_no_duplicate_comma_items("row_normalization.pair.hard_row_negative", pair["hardcore_row"].get("negative_prompt")) +def smoke_row_rendering_policy() -> None: + _expect(pb.SINGLE_TEMPLATE == row_rendering.SINGLE_TEMPLATE, "Prompt builder single template should delegate to row_rendering") + _expect( + pb._format("Known {known}, missing {missing}", {"known": 7}) + == row_rendering.format_template("Known {known}, missing {missing}", {"known": 7}), + "Prompt builder safe formatter should delegate to row_rendering", + ) + _expect( + row_rendering.format_template("Known {known}, missing {missing}", {"known": 7}) == "Known 7, missing {missing}", + "Row rendering changed missing-field preservation", + ) + _expect( + row_rendering.prompt_template_for({}, {}, {}, "woman") == row_rendering.SINGLE_TEMPLATE, + "Row rendering default woman template changed", + ) + _expect( + row_rendering.prompt_template_for({}, {}, {}, "group") == row_rendering.GROUP_TEMPLATE, + "Row rendering default group template changed", + ) + + context = { + "trigger": Trigger, + "subject": "configured cast", + "subject_phrase": "configured adult cast", + "age": "adult", + "body": "varied", + "body_phrase": "varied", + "skin": "", + "hair": "", + "eyes": "", + "item_label": "Scene", + "item": "shared action", + "scene": "warm room", + "pose": "standing close", + "expression": "focused look", + "composition": "centered frame", + "composition_prompt": "vertical centered frame", + "positive_suffix": "clear readable bodies.", + "negative_prompt": "bad anatomy", + "cast_descriptors": "Woman A: adult woman; Man A: adult man", + } + rendered = row_rendering.render_prompt_caption( + item={}, + subcategory={ + "prompt_template": "Scene: {item}. Composition: {composition_prompt}. Avoid: {negative_prompt}.", + "caption_template": "{trigger}, {item}, {scene}", + }, + category={}, + subject_type="configured_cast", + context=context, + cast_descriptor_text="Woman A: adult woman; Man A: adult man", + pov_prompt_directive="First-person POV from Man A.", + ) + prompt = rendered["prompt"] + caption = rendered["caption"] + _expect("Characters: Woman A: adult woman; Man A: adult man." in prompt, "Row rendering lost configured-cast descriptors") + _expect("First-person POV from Man A." in prompt, "Row rendering lost configured-cast POV directive") + _expect( + prompt.index("Characters:") < prompt.index("First-person POV") < prompt.index("Avoid:"), + "Row rendering did not insert configured-cast directives before negative prompt", + ) + _expect( + caption.endswith("Woman A: adult woman; Man A: adult man"), + "Row rendering did not append descriptors to captions without descriptor placeholders", + ) + + def smoke_formatter_input_policy() -> None: source_row = { "prompt": "A simple adult portrait. Setting: quiet studio. Pose: standing calmly. Avoid: low quality.", @@ -4197,6 +4265,7 @@ SMOKE_CASES: list[tuple[str, Callable[[], None]]] = [ ("character_config_policy", smoke_character_config_policy), ("character_profile_policy", smoke_character_profile_policy), ("row_normalization_policy", smoke_row_normalization_policy), + ("row_rendering_policy", smoke_row_rendering_policy), ("formatter_input_policy", smoke_formatter_input_policy), ("formatter_cast_policy", smoke_formatter_cast_policy), ("caption_policy", smoke_caption_policy),