diff --git a/pair_clothing.py b/pair_clothing.py index e2a9672..f52b364 100644 --- a/pair_clothing.py +++ b/pair_clothing.py @@ -269,12 +269,36 @@ def hardcore_row_access_flags(row: dict[str, Any]) -> dict[str, bool]: def _outfit_without_lower_body_blockers(outfit: str) -> str: + _removed, remaining = _outfit_split_by_terms( + outfit, + LOWER_BODY_CLOTHING_TERMS, + replacements=( + (r"\blingerie set\b", "lingerie top details"), + (r"\bbrief set\b", "bra set"), + (r"\bbodysuit with\b", "upper bodysuit detail with"), + ), + ) + return remaining + + +def _outfit_without_upper_body_blockers(outfit: str) -> str: + _removed, remaining = _outfit_split_by_terms( + outfit, + UPPER_BODY_CLOTHING_TERMS, + replacements=( + (r"\blingerie set\b", "lingerie styling"), + (r"\bbalconette bra and brief set\b", "briefs and garter styling"), + ), + ) + return remaining + + +def _split_outfit_fragments(outfit: str, replacements: tuple[tuple[str, str], ...] = ()) -> list[str]: text = str(outfit or "").strip() if not text: - return "" - text = re.sub(r"\blingerie set\b", "lingerie top details", text, flags=re.IGNORECASE) - text = re.sub(r"\bbrief set\b", "bra set", text, flags=re.IGNORECASE) - text = re.sub(r"\bbodysuit with\b", "upper bodysuit detail with", text, flags=re.IGNORECASE) + return [] + for pattern, replacement in replacements: + text = re.sub(pattern, replacement, text, flags=re.IGNORECASE) fragments = re.split(r"\s*,\s*|\s+\band\b\s+|\s+\bwith\b\s+|\s+\bunder\b\s+|\s+\bover\b\s+", text) kept = [] for fragment in fragments: @@ -282,12 +306,7 @@ def _outfit_without_lower_body_blockers(outfit: str) -> str: fragment = re.sub(r"^(?:and|with|under|over)\s+", "", fragment, flags=re.IGNORECASE) if not fragment: continue - lower = fragment.lower() - if any(term in lower for term in LOWER_BODY_CLOTHING_TERMS): - continue kept.append(fragment) - if not kept: - return "" deduped = [] seen = set() for fragment in kept: @@ -295,36 +314,82 @@ def _outfit_without_lower_body_blockers(outfit: str) -> str: if key and key not in seen: deduped.append(fragment) seen.add(key) - return ", ".join(deduped) + return deduped -def _outfit_without_upper_body_blockers(outfit: str) -> str: - text = str(outfit or "").strip() - if not text: - return "" - text = re.sub(r"\blingerie set\b", "lingerie styling", text, flags=re.IGNORECASE) - text = re.sub(r"\bbalconette bra and brief set\b", "briefs and garter styling", text, flags=re.IGNORECASE) - fragments = re.split(r"\s*,\s*|\s+\band\s+|\s+\bwith\s+|\s+\bunder\s+|\s+\bover\s+", text) - kept = [] - for fragment in fragments: - fragment = fragment.strip(" ,.;") - fragment = re.sub(r"^(?:and|with|under|over)\s+", "", fragment, flags=re.IGNORECASE) - if not fragment: - continue +def _join_fragments(fragments: list[str]) -> str: + return ", ".join(fragment for fragment in fragments if fragment) + + +def _outfit_split_by_terms( + outfit: str, + terms: tuple[str, ...], + replacements: tuple[tuple[str, str], ...] = (), +) -> tuple[str, str]: + removed: list[str] = [] + remaining: list[str] = [] + for fragment in _split_outfit_fragments(outfit, replacements): lower = fragment.lower() - if any(term in lower for term in UPPER_BODY_CLOTHING_TERMS): - continue - kept.append(fragment) - if not kept: - return "" - deduped = [] - seen = set() - for fragment in kept: - key = re.sub(r"\W+", " ", fragment.lower()).strip() - if key and key not in seen: - deduped.append(fragment) - seen.add(key) - return ", ".join(deduped) + if any(term in lower for term in terms): + removed.append(fragment) + else: + remaining.append(fragment) + return _join_fragments(removed), _join_fragments(remaining) + + +def _is_plural_clothing_phrase(text: str) -> bool: + lower = text.lower() + if "," in text or " and " in lower: + return True + return any(term in lower for term in ("briefs", "panties", "shorts", "jeans", "trousers", "pants", "stockings")) + + +def _partially_removed_outfit_state(outfit: str, woman_access: str, implied: bool = False) -> str: + outfit = str(outfit or "").strip() + if not outfit: + return "Woman A's body is partly exposed" if implied else "Woman A's outfit is pushed aside where needed" + if woman_access == "lower": + removed, remaining = _outfit_split_by_terms( + outfit, + LOWER_BODY_CLOTHING_TERMS, + replacements=( + (r"\blingerie set\b", "lingerie top details"), + (r"\bbrief set\b", "bra set"), + (r"\bbodysuit with\b", "upper bodysuit detail with"), + ), + ) + verb = "are" if _is_plural_clothing_phrase(removed) else "is" + lead = ( + f"Woman A's {removed} {verb} pulled aside or removed below the hips" + if removed + else "Woman A's lower body is clear, with the outfit pulled aside below the hips" + ) + if remaining: + remain_verb = "remain" if _is_plural_clothing_phrase(remaining) else "remains" + return f"{lead}; {remaining} {remain_verb} visible from the same outfit" + return lead + if woman_access == "upper": + removed, remaining = _outfit_split_by_terms( + outfit, + UPPER_BODY_CLOTHING_TERMS, + replacements=( + (r"\blingerie set\b", "lingerie styling"), + (r"\bbalconette bra and brief set\b", "briefs and garter styling"), + ), + ) + verb = "are" if _is_plural_clothing_phrase(removed) else "is" + lead = ( + f"Woman A's {removed} {verb} pulled open or pushed aside from her breasts and chest" + if removed + else "Woman A's upper body is clear, with the outfit pulled open at the chest" + ) + if remaining: + remain_verb = "remain" if _is_plural_clothing_phrase(remaining) else "remains" + return f"{lead}; {remaining} {remain_verb} visible from the same outfit" + return lead + if implied: + return f"Woman A's {outfit} is loosened and partly slipping off, leaving her body partly exposed" + return f"Woman A's {outfit} is pushed aside and partly removed where needed" def hardcore_clothing_state( @@ -340,22 +405,10 @@ def hardcore_clothing_state( base = continuity_map[mode] if mode == "explicit_nude": return f"Body exposure: {base}." - if mode == "implied_nude": - return f"Body exposure: {base}." - if mode == "partially_removed" and woman_access == "lower": - detail = _outfit_without_lower_body_blockers(outfit) - base = "Woman A's lower body is clear; any lower garment is pulled aside or removed below the hips" - if detail: - return f"Clothing state: {base}; visible remaining styling: {detail}." - return f"Clothing state: {base}." - if mode == "partially_removed" and woman_access == "upper": - detail = _outfit_without_upper_body_blockers(outfit) - base = "Woman A's breasts and upper body are clear; any bra cup, bodice, or top panel is pulled aside or removed" - if detail: - return f"Clothing state: {base}; visible remaining styling: {detail}." - return f"Clothing state: {base}." if mode == "partially_removed": - return f"Clothing state: Woman A keeps the outfit mostly on; teaser outfit detail: {outfit}." + return f"Clothing state: {_partially_removed_outfit_state(outfit, woman_access)}." + if mode == "implied_nude": + return f"Clothing state: {_partially_removed_outfit_state(outfit, woman_access, implied=True)}." return f"Clothing state: {base}; teaser outfit detail: {outfit}." diff --git a/tools/prompt_smoke.py b/tools/prompt_smoke.py index 8434feb..bc1dfa8 100644 --- a/tools/prompt_smoke.py +++ b/tools/prompt_smoke.py @@ -6297,12 +6297,24 @@ def smoke_pair_route_policy() -> None: _expect(clothing_route.as_dict() == clothing_legacy, "Typed pair clothing route should match legacy dict route") _expect(clothing_route.woman_access == "lower", "Typed pair clothing route lost lower-access detection") _expect(clothing_route.requires_body_exposure_scene is True, "Typed pair clothing route lost exposure-scene flag") - partial_common = {**clothing_common, "mode": "partially_removed"} + continuity_outfit = "button-down shirt tied at the waist over a fitted bralette and denim shorts" + partial_common = {**clothing_common, "mode": "partially_removed", "softcore_outfit": continuity_outfit} partial_route = pair_clothing.resolve_hardcore_pair_clothing_result(**partial_common) _expect(partial_route.requires_body_exposure_scene is True, "Partial lower-access clothing should request scene cleanup") + partial_lower = partial_route.hardcore_clothing_state.lower() + _expect("denim shorts" in partial_lower, "Partial lower-access clothing should name removed lower softcore garment") + _expect("below the hips" in partial_lower, "Partial lower-access clothing should describe lower-garment removal") + _expect("button-down shirt" in partial_lower and "fitted bralette" in partial_lower, "Partial lower-access clothing lost remaining softcore outfit styling") + implied_route = pair_clothing.resolve_hardcore_pair_clothing_result( + **{**clothing_common, "mode": "implied_nude", "softcore_outfit": continuity_outfit} + ) + implied_lower = implied_route.hardcore_clothing_state.lower() + _expect("fabric slipping off" not in implied_lower, "Implied nude clothing should not fall back to generic fabric slipping") + _expect("denim shorts" in implied_lower and "fitted bralette" in implied_lower, "Implied nude clothing should mirror softcore outfit pieces") structured_axis_clothing = pair_clothing.resolve_hardcore_pair_clothing_result( **{ **clothing_common, + "softcore_outfit": continuity_outfit, "hard_row": { "role_graph": "generic adult action", "item": "generic configured action", @@ -6495,13 +6507,16 @@ def smoke_krea_pair_clothing_state() -> None: prompt = _expect_text("krea_pair_clothing_state.krea_prompt", krea.get("krea_prompt"), 60) lower = prompt.lower() root_clothing = _clean_key(pair.get("hardcore_clothing_state")) - _expect("lower body is clear" in root_clothing, "pair root clothing state lost lower-body access wording") + _expect( + "below the hips" in root_clothing, + "pair root clothing state lost lower-body removal wording", + ) _expect(pair.get("default_man_hardcore_clothing"), "pair root default man hardcore clothing is missing") _expect("metadata" in krea.get("method", ""), "pair clothing route did not use metadata") _expect("clothing state:" not in lower, "Krea clothing route leaked raw clothing label") _expect("visual clothing state" not in lower, "Krea clothing route fell back to visual clothing state label") _expect("softcore outfit" not in lower and "teaser outfit" not in lower, "Krea clothing route leaked softcore outfit label") - _expect("lower body is clear" in lower, "Krea clothing route lost generated clothing continuity") + _expect("below the hips" in lower, "Krea clothing route lost generated lower-body clothing continuity") _expect("the man keeps" in lower, "Krea clothing route lost partner clothing continuity") _expect("outfit racks" not in lower and "shoe shelves" not in lower, "Krea pair formatter leaked unsanitized hard scene") hard_scene = _clean_key(pair["hardcore_row"].get("scene_text"))