"""이미지 생성 거부가 **검열인가**를 가르는 공용 판별 (2026-08-19).

왜 공용인가: 시네마틱 변환 경로에는 이 판별이 아예 없어서 검열 거부를
여느 오류와 똑같이 다뤘다.
★2026-08-20 정정: "같은 판별을 쓰던 자리를 모았다"고 읽지 말 것 — 옮긴
것은 grok 이미지 클라이언트 한 자리뿐이고, **후퇴 사다리의 인라인 사본**
(multiroll_gemini._moderated)은 그대로 남아 있다(낱말 집합이 같아 동작은
같다). 그 밖에도 서로 **다른** 낱말 집합을 쓰는 자리가 여럿이다
(shot_conti_light_step·background_chain_render 등). 한쪽 집합만 넓히면
사다리와 포기 셈이 같은 거부를 다르게 분류하니, 넓힐 때는 반드시 함께.
그 결과 될 리 없는 호출을 재개마다 다시 보냈고 **거부당해도 요금이
나갔다**(xAI 거부 응답에 `cost_in_usd_ticks` 가 붙는다 — 08-19 실측).

판별 대상은 **제공자가 돌려준 오류 문자열**이지 장면 내용이 아니다.
같은 거부가 두 모양으로 온다는 것이 실측이라 문자열을 봐야 한다:

  · HTTP 200 + 본문 오류 —
    `{"message": "Generated image rejected by content moderation.",
      "code": 400}`                                        (S49sh10)
  · HTTP 400 + 제공자 원문 —
    `{"error":{"message":"Provider returned error","code":400,
      "metadata":{"raw":"{\\"code\\":\\"imagine:content-moderated\\",
      \\"error\\":\\"Generated image rejected by content moderation.\\"…`
                                                            (S73sh6)

표식 목록은 종전 두 자리(`grok_image_client` · `multiroll_gemini`)에
있던 것과 **같은 집합**이다 — 옮기면서 동작을 바꾸지 않는다.
"""
from __future__ import annotations

# 제공자 오류 문자열에 나타나는 검열 표식. 소문자로 대조한다.
MODERATION_MARKERS = ("moderation", "safety", "content_policy", "blocked")


def is_moderation_text(text: str) -> bool:
    low = (text or "").lower()
    return any(marker in low for marker in MODERATION_MARKERS)


def is_moderation_error(exc: BaseException) -> bool:
    return is_moderation_text(str(exc))
