From 61535cc60d813e96048fe949f5f44d4e4ffabe21 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Sat, 27 Jun 2026 03:22:25 +0200 Subject: [PATCH] Extract shared POV policy --- docs/prompt-architecture-improvement-plan.md | 9 +- docs/prompt-pool-routing-map.md | 10 +- krea_pov.py | 58 ++------ pov_policy.py | 139 +++++++++++++++++++ prompt_builder.py | 49 ++----- tools/prompt_smoke.py | 28 ++++ 6 files changed, 197 insertions(+), 96 deletions(-) create mode 100644 pov_policy.py diff --git a/docs/prompt-architecture-improvement-plan.md b/docs/prompt-architecture-improvement-plan.md index c798ab5..b05c2d5 100644 --- a/docs/prompt-architecture-improvement-plan.md +++ b/docs/prompt-architecture-improvement-plan.md @@ -182,6 +182,10 @@ Already isolated: camera-scene prose lives in `scene_camera_adapters.py`; row-level camera insertion, contextual coworking composition mutation, subject-kind detection, and POV suppression live in `row_camera.py`. +- shared POV slot detection, label merging/filtering, builder-side POV + directives, source role-graph viewer replacement, and shared composition + cleanup live in `pov_policy.py`; prompt builder and Krea POV routes delegate + to it. - shared hardcore environment-anchor cleanup lives in `hardcore_text_cleanup.py` and normalizes malformed pool joins before metadata reaches formatter routes. @@ -263,8 +267,9 @@ Already isolated: POV formatter routes. - `hardcore_action_metadata.py` owns shared action-family constants, normalization, and inference used by the builder and Krea formatter route. -- `krea_pov.py` owns POV labels, POV label filtering, and POV camera/composition - support text. +- `pov_policy.py` owns shared POV labels, label filtering, source role-graph + viewer replacement, and composition cleanup; `krea_pov.py` owns Krea-specific + POV camera support text while delegating shared POV policy. - `krea_detail.py` owns generic detail-clause splitting, deduping, joining, and density limiting for Krea action prose. - `krea_action_positions.py` owns non-POV pose anchors, body-arrangement text, diff --git a/docs/prompt-pool-routing-map.md b/docs/prompt-pool-routing-map.md index 2053260..dcc1fae 100644 --- a/docs/prompt-pool-routing-map.md +++ b/docs/prompt-pool-routing-map.md @@ -94,6 +94,7 @@ Core helper ownership: | `hardcore_role_climax.py` | Climax and ejaculation aftermath role graph wording for face/body/ass, lap, open-thigh, side-lying, and group front/back placement. | | `hardcore_action_metadata.py` | Source action-family and position-family metadata used by Krea2, SDXL, and caption routes. | | `route_metadata.py` | Shared row-level route metadata readers for normalized action family, position family/keys, and formatter hints used by Krea2, SDXL, and caption routes. | +| `pov_policy.py` | Shared POV slot detection, POV label merging/filtering, builder POV directives, source role-graph viewer replacement, and shared POV composition cleanup used by builder and Krea2 routes. | | `scene_camera_adapters.py` | Location-aware camera/scene prose such as coworking lounge camera layout. | | `row_camera.py` | Row-level camera insertion, contextual coworking composition mutation, subject-kind detection, POV label fallback, and POV suppression of normal camera directives. | | `krea_cast.py` | Shared formatter cast descriptor parsing, cast labels, cast prose, natural cast descriptor text, and label replacement used by Krea2 and caption routes. | @@ -334,6 +335,8 @@ Edit targets: - Krea2 climax role/detail cleanup: `krea_action_climax.py`. - Krea2 non-POV action-family routing: `krea_action_dispatch.py`. - Krea2 non-POV action sentence assembly: `krea_actions.py`. +- Shared POV labels/composition cleanup: `pov_policy.py`. +- Krea2 POV camera support: `krea_pov.py`. - Krea2 POV position rewrite: `krea_pov_actions.py`. ### Composition @@ -637,7 +640,8 @@ Key Krea2 ownership: - Climax role/detail cleanup: `krea_action_climax.py`. - Non-POV action-family routing: `krea_action_dispatch.py`. - Non-POV hardcore action sentence: `krea_actions.hardcore_action_sentence`. -- POV labels, filtering, and camera/composition support: `krea_pov.py`. +- 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`. - POV hardcore sentence: `krea_pov_actions.pov_action_phrase`. - Clothing state cleanup: `krea_clothing.natural_clothing_state`. @@ -871,8 +875,8 @@ Use these traces to narrow a problem in one pass. 2. Confirm Krea input uses metadata, not plain prompt fallback. 3. Inspect `source_role_graph`, `item`, `source_composition`, and `item_axis_values`. -4. Inspect `krea_pov.py` if the label omission, camera phrase, or POV - composition cleanup is wrong. +4. Inspect `pov_policy.py` if label omission or POV composition cleanup is + wrong; inspect `krea_pov.py` if the Krea camera phrase is wrong. 5. Edit `krea_pov_actions.py` if the first-person body geometry is wrong. 6. Edit `sexual_poses.json` if the raw action lacks enough body-position anchor for any formatter to infer a good POV prompt. diff --git a/krea_pov.py b/krea_pov.py index b7bc7e7..1dde1be 100644 --- a/krea_pov.py +++ b/krea_pov.py @@ -1,50 +1,23 @@ from __future__ import annotations -import re from typing import Any - -def _clean(value: Any) -> str: - text = "" if value is None else str(value) - text = text.replace("\n", " ") - text = re.sub(r"\s+", " ", text).strip() - text = re.sub(r"\s+([,.;:])", r"\1", text) - return text +try: + from . import pov_policy +except ImportError: # Allows local smoke tests with top-level imports. + import pov_policy def pov_labels_from_value(value: Any) -> list[str]: - labels: list[str] = [] - if isinstance(value, list): - candidates = value - else: - candidates = re.split(r"[,;]\s*", _clean(value)) if _clean(value) else [] - for candidate in candidates: - label = _clean(candidate) - if re.match(r"^Man [A-Z]$", label) and label not in labels: - labels.append(label) - return labels + return pov_policy.pov_labels_from_value(value) def merge_labels(*groups: list[str]) -> list[str]: - merged: list[str] = [] - for group in groups: - for label in group: - if label and label not in merged: - merged.append(label) - return merged + return pov_policy.merge_labels(*groups) def filter_pov_labeled_clauses(text: Any, pov_labels: list[str]) -> str: - rendered = _clean(text) - if not rendered or not pov_labels: - return rendered - clauses = [clause.strip() for clause in rendered.split(";") if clause.strip()] - filtered = [ - clause - for clause in clauses - if not any(re.match(rf"^{re.escape(label)}\b", clause) for label in pov_labels) - ] - return "; ".join(filtered) + return pov_policy.filter_pov_labeled_clauses(text, pov_labels) def pov_camera_phrase(pov_labels: list[str], softcore: bool = False) -> str: @@ -60,19 +33,4 @@ def pov_camera_phrase(pov_labels: list[str], softcore: bool = False) -> str: def pov_composition_text(composition: Any, pov_labels: list[str]) -> str: - text = _clean(composition) - if not text or not pov_labels: - return text - text = re.sub(r"\ball participants visible\b", "visible partners readable", text, flags=re.IGNORECASE) - text = re.sub(r"\ball adult bodies visible\b", "visible partners readable", text, flags=re.IGNORECASE) - text = re.sub(r"\ball bodies visible\b", "visible partners readable", text, flags=re.IGNORECASE) - text = re.sub(r"\ball three bodies readable\b", "visible partner bodies readable", text, flags=re.IGNORECASE) - text = re.sub(r"\bwide group-sex composition\b", "first-person group-sex POV composition", text, flags=re.IGNORECASE) - text = re.sub( - r",?\s*adapted for first-person POV with the POV participant kept off-camera\b", - "", - text, - flags=re.IGNORECASE, - ) - text = re.sub(r",?\s*with the POV participant kept off-camera\b", "", text, flags=re.IGNORECASE) - return _clean(text) + return pov_policy.pov_composition_formatter_text(composition, pov_labels) diff --git a/pov_policy.py b/pov_policy.py new file mode 100644 index 0000000..699af33 --- /dev/null +++ b/pov_policy.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +import re +from typing import Any + + +def clean_pov_text(value: Any) -> str: + text = "" if value is None else str(value) + text = text.replace("\n", " ") + text = re.sub(r"\s+", " ", text).strip() + text = re.sub(r"\s+([,.;:])", r"\1", text) + text = re.sub(r"(?:,\s*){2,}", ", ", text) + text = re.sub(r"\.\s*\.", ".", text) + text = re.sub(r":\s*\.", ".", text) + return text.strip() + + +def slot_is_pov(slot: dict[str, Any] | None) -> bool: + if not slot: + return False + return slot.get("subject_type") == "man" and slot.get("presence_mode") == "pov" + + +def pov_labels_from_value(value: Any) -> list[str]: + labels: list[str] = [] + if isinstance(value, list): + candidates = value + else: + text = clean_pov_text(value) + candidates = re.split(r"[,;]\s*", text) if text else [] + for candidate in candidates: + label = clean_pov_text(candidate) + if re.match(r"^Man [A-Z]$", label) and label not in labels: + labels.append(label) + return labels + + +def merge_labels(*groups: list[str]) -> list[str]: + merged: list[str] = [] + for group in groups: + for label in group: + if label and label not in merged: + merged.append(label) + return merged + + +def pov_character_labels( + label_map: dict[str, dict[str, Any]], + men_count: int | None = None, +) -> list[str]: + if men_count is None: + labels = sorted(label for label in label_map if label.startswith("Man ")) + else: + labels = [f"Man {chr(ord('A') + index)}" for index in range(max(0, men_count))] + return [label for label in labels if slot_is_pov(label_map.get(label))] + + +def filter_pov_labeled_clauses(text: Any, pov_labels: list[str]) -> str: + rendered = clean_pov_text(text) + if not rendered or not pov_labels: + return rendered + clauses = [clause.strip() for clause in rendered.split(";") if clause.strip()] + filtered = [ + clause + for clause in clauses + if not any(re.match(rf"^{re.escape(label)}\b", clause) for label in pov_labels) + ] + return "; ".join(filtered) + + +def pov_text_with_viewer(text: Any, pov_labels: list[str]) -> str: + rendered = clean_pov_text(text) + if not rendered or not pov_labels: + return rendered + for label in sorted(pov_labels, key=len, reverse=True): + escaped = re.escape(label) + rendered = re.sub(rf"\b{escaped}'s\b", "the POV viewer's", rendered) + rendered = re.sub(rf"\b{escaped}\b", "the POV viewer", rendered) + rendered = re.sub( + r"\bthe POV viewer is positioned\b", + "the POV camera is positioned", + rendered, + flags=re.IGNORECASE, + ) + return clean_pov_text(rendered) + + +def pov_role_graph_prompt(role_graph: Any, pov_labels: list[str]) -> str: + role_graph_text = clean_pov_text(role_graph) + if not role_graph_text or not pov_labels: + return role_graph_text + viewer_text = pov_text_with_viewer(role_graph_text, pov_labels) + label_text = ", ".join(pov_labels) + return f"First-person POV from {label_text}; {viewer_text}" + + +def pov_prompt_directive(pov_labels: list[str]) -> str: + if not pov_labels: + return "" + label_text = ", ".join(pov_labels) + return ( + f"POV participant: {label_text} is the first-person camera viewpoint; " + "he remains the off-camera viewpoint, represented by foreground hands, body position, or camera perspective cues when needed." + ) + + +def pov_composition_base_text(composition: Any, pov_labels: list[str]) -> str: + text = clean_pov_text(composition) + if not text or not pov_labels: + return text + text = re.sub(r"\ball participants visible\b", "visible partners readable", text, flags=re.IGNORECASE) + text = re.sub(r"\ball adult bodies visible\b", "visible partners readable", text, flags=re.IGNORECASE) + text = re.sub(r"\ball bodies visible\b", "visible partners readable", text, flags=re.IGNORECASE) + text = re.sub(r"\ball three bodies readable\b", "visible partner bodies readable", text, flags=re.IGNORECASE) + text = re.sub(r"\bwide group-sex composition\b", "first-person group-sex POV composition", text, flags=re.IGNORECASE) + return clean_pov_text(text) + + +def pov_composition_prompt(composition: Any, pov_labels: list[str]) -> str: + text = pov_composition_base_text(composition, pov_labels) + if not text or not pov_labels: + return text + if "pov" not in text.lower() and "first-person" not in text.lower(): + text = f"{text}, adapted for first-person POV with the POV participant kept off-camera" + return clean_pov_text(text) + + +def pov_composition_formatter_text(composition: Any, pov_labels: list[str]) -> str: + text = pov_composition_base_text(composition, pov_labels) + if not text or not pov_labels: + return text + text = re.sub( + r",?\s*adapted for first-person POV with the POV participant kept off-camera\b", + "", + text, + flags=re.IGNORECASE, + ) + text = re.sub(r",?\s*with the POV participant kept off-camera\b", "", text, flags=re.IGNORECASE) + return clean_pov_text(text) diff --git a/prompt_builder.py b/prompt_builder.py index 36b9356..23d77ae 100644 --- a/prompt_builder.py +++ b/prompt_builder.py @@ -39,6 +39,7 @@ try: from . import pair_output from . import pair_rows from . import pair_options + from . import pov_policy from . import row_normalization as row_policy from . import row_camera as row_camera_policy from . import row_location as row_location_policy @@ -81,6 +82,7 @@ except ImportError: # Allows local smoke tests with `python -c`. import pair_output import pair_rows import pair_options + import pov_policy import row_normalization as row_policy import row_camera as row_camera_policy import row_location as row_location_policy @@ -1790,9 +1792,7 @@ def _normalize_presence_mode(value: Any, subject_type: str) -> str: def _slot_is_pov(slot: dict[str, Any] | None) -> bool: - if not slot: - return False - return slot.get("subject_type") == "man" and slot.get("presence_mode") == "pov" + return pov_policy.slot_is_pov(slot) def _normalize_slot_expression_intensity(value: Any) -> float: @@ -2457,56 +2457,23 @@ def _pov_character_labels( label_map: dict[str, dict[str, Any]], men_count: int | None = None, ) -> list[str]: - if men_count is None: - labels = sorted(label for label in label_map if label.startswith("Man ")) - else: - labels = [f"Man {chr(ord('A') + index)}" for index in range(max(0, men_count))] - return [label for label in labels if _slot_is_pov(label_map.get(label))] + return pov_policy.pov_character_labels(label_map, men_count) def _pov_text_with_viewer(text: Any, pov_labels: list[str]) -> str: - rendered = str(text or "").strip() - if not rendered or not pov_labels: - return rendered - for label in sorted(pov_labels, key=len, reverse=True): - escaped = re.escape(label) - rendered = re.sub(rf"\b{escaped}'s\b", "the POV viewer's", rendered) - rendered = re.sub(rf"\b{escaped}\b", "the POV viewer", rendered) - rendered = re.sub(r"\bthe POV viewer is positioned\b", "the POV camera is positioned", rendered, flags=re.IGNORECASE) - return _clean_prompt_punctuation(rendered) + return pov_policy.pov_text_with_viewer(text, pov_labels) def _pov_role_graph_prompt(role_graph: Any, pov_labels: list[str]) -> str: - role_graph_text = str(role_graph or "").strip() - if not role_graph_text or not pov_labels: - return role_graph_text - viewer_text = _pov_text_with_viewer(role_graph_text, pov_labels) - label_text = ", ".join(pov_labels) - return f"First-person POV from {label_text}; {viewer_text}" + return pov_policy.pov_role_graph_prompt(role_graph, pov_labels) def _pov_prompt_directive(pov_labels: list[str]) -> str: - if not pov_labels: - return "" - label_text = ", ".join(pov_labels) - return ( - f"POV participant: {label_text} is the first-person camera viewpoint; " - "he remains the off-camera viewpoint, represented by foreground hands, body position, or camera perspective cues when needed." - ) + return pov_policy.pov_prompt_directive(pov_labels) def _pov_composition_prompt(composition: Any, pov_labels: list[str]) -> str: - text = str(composition or "").strip() - if not text or not pov_labels: - return text - text = re.sub(r"\ball participants visible\b", "visible partners readable", text, flags=re.IGNORECASE) - text = re.sub(r"\ball adult bodies visible\b", "visible partners readable", text, flags=re.IGNORECASE) - text = re.sub(r"\ball bodies visible\b", "visible partners readable", text, flags=re.IGNORECASE) - text = re.sub(r"\ball three bodies readable\b", "visible partner bodies readable", text, flags=re.IGNORECASE) - text = re.sub(r"\bwide group-sex composition\b", "first-person group-sex POV composition", text, flags=re.IGNORECASE) - if "pov" not in text.lower() and "first-person" not in text.lower(): - text = f"{text}, adapted for first-person POV with the POV participant kept off-camera" - return _clean_prompt_punctuation(text) + return pov_policy.pov_composition_prompt(composition, pov_labels) def _body_exposure_scene_text(scene: Any) -> str: diff --git a/tools/prompt_smoke.py b/tools/prompt_smoke.py index 7c4c764..c6f5192 100644 --- a/tools/prompt_smoke.py +++ b/tools/prompt_smoke.py @@ -42,6 +42,7 @@ import krea_formatter # noqa: E402 import location_config # noqa: E402 import loop_nodes # noqa: E402 import prompt_builder as pb # noqa: E402 +import pov_policy # noqa: E402 import row_normalization # noqa: E402 import route_metadata # noqa: E402 import row_camera # noqa: E402 @@ -50,6 +51,7 @@ import server_routes # noqa: E402 import sdxl_formatter # noqa: E402 import sdxl_presets # noqa: E402 import seed_config # noqa: E402 +import krea_pov # noqa: E402 Trigger = "sxcppnl7" @@ -798,6 +800,32 @@ def smoke_character_config_policy() -> None: _expect(hair.get("styles") == ["messy_bun", "straight"], "Hair style config merge changed") _expect(pb._hair_phrase_from_parts("platinum_blonde", "long", "messy_bun") == "long platinum-blonde hair in a messy bun", "Hair phrase helper changed") _expect(character_config.normalize_presence_mode("pov", "woman") == "visible", "POV presence should stay man-only") + pov_slot = {"subject_type": "man", "presence_mode": "pov"} + visible_slot = {"subject_type": "man", "presence_mode": "visible"} + _expect(pb._slot_is_pov(pov_slot) is True, "Prompt builder POV slot helper should delegate to POV policy") + _expect(pov_policy.slot_is_pov(visible_slot) is False, "Visible man slot should not be POV") + _expect( + pb._pov_character_labels({"Man A": pov_slot, "Man B": visible_slot}, 2) == ["Man A"], + "POV label selection should keep only POV men in count order", + ) + _expect( + pb._pov_role_graph_prompt("Man A is positioned behind Woman A", ["Man A"]) + == "First-person POV from Man A; the POV camera is positioned behind Woman A", + "Builder POV role graph prompt should use shared viewer replacement", + ) + _expect( + pb._pov_composition_prompt("wide group-sex composition with all bodies visible", ["Man A"]) + == "first-person group-sex POV composition with visible partners readable", + "Builder POV composition prompt should use shared POV composition replacements", + ) + _expect( + krea_pov.pov_composition_text( + "wide group-sex composition with all bodies visible, adapted for first-person POV with the POV participant kept off-camera", + ["Man A"], + ) + == "first-person group-sex POV composition with visible partners readable", + "Krea POV composition cleanup should delegate shared replacements and strip builder annotation", + ) _expect(character_config.normalize_slot_seed(0xFFFFFFFF + 99) == 0xFFFFFFFF, "Slot seed clamp changed")