# Patch B — Sanitizer Polarity Guard (B-min)

Version: v1 (initial)
Date: 2026-05-11
Pack timestamp: `prompt_sanitizer/2.202605112104` (예정)
Patch family: Visual QA story prop binding (Patch A 후속 — see `2026-05-11-visual-qa-story-prop-binding-plan.md` §1.2).

---

## 1. Problem

### 1.1 결함 정의

`scene_image_pipeline.generate_and_validate_scene()` 의 T2I 생성 경로에서 Gemini 가 prompt 를 moderation block 했을 때, `PromptSanitizer.sanitize()` 가 LLM 으로 prompt 를 rewrite 한다. 현재 sanitizer 의 3 strategy (`film_previs` / `movie_poster` / `aftermath`) 와 system prompt 가 *부상/피 → 감정 표현 (고통, 결의)* / *폭력 → 직후 정적 장면 (characters processing what just happened)* 의 alive-rewrite 를 강제하므로, **움직일 수 없거나 자세가 고정된 subject (시신 / 의식불명 / 중상 / 결박 등)** 의 묘사가 살아 움직이는 인물로 변형된다. 시신/포즈락 prop 의 자세 무결성 깨짐.

S11/14·S11/20 시각 결함 영역.

### 1.2 Root cause (소스 확인)

- `backend/app/modules/prompt_sanitizer.py:41` (`movie_poster.prefix`): "dramatic poses".
- `backend/app/modules/prompt_sanitizer.py:50` (`aftermath.prefix`): "Characters process what just happened".
- `prompts/_base/prompt_sanitizer/v1/sanitize_system.md:14`: "부상/피: 부상 묘사 → 감정 표현(고통, 결의)으로 대체".
- `backend/app/modules/prompt_sanitizer.py:133`: strategy prefix 가 최종 sanitized_prompt 에 강제 prepend.

위 4 site 가 결합되어 immobilized subject 에 대한 polarity 위반을 결정적 (deterministic) 으로 유도한다.

### 1.3 Architectural orientation

장면 해석 (subject 가 움직일 수 있는지 / 자세 고정인지 / 사망 상태인지 판단) 은 LLM 전용 영역. regex / heuristic / downstream LVM 모두 정확도 부족.

따라서:
- **Detection**: LLM 이 이미 만든 structured SOT (`shot_staging.character_angles[].gaze_target`, `render_prompt_card.continuity_elements_used.fixed_elements`) 만 읽음. router 는 deterministic consumer.
- **Sanitize 시점 차단**: `PromptSanitizer.sanitize()` 에 `semantic_constraints` optional param 추가. caller 가 router 출력 전달.
- **2 layer defense**:
  1. user 메시지 `SEMANTIC CONSTRAINTS` 섹션 — sanitize LLM 에게 polarity 보존 지시.
  2. final sanitized_prompt 끝에 deterministic `SEMANTIC OVERRIDE` block — T2I 모델 (Gemini) 이 마지막 instruction 마주칠 때 strategy prefix 효과 override.
- **post-check / LLM judge / LVM gate 없음** — 다국어 한계 + 현재 LVM 정확도 부족. drift 시 운영자가 redo-shot endpoint (Patch A `f31dfa5`) 로 단건 재시도.

---

## 2. Scope B-min

### 2.1 In scope

이번 patch 는 다음만 한다:

1. **SemanticContractRouter 신설** — `backend/app/modules/semantic_contract_router.py`.
2. **PromptSanitizer.sanitize() 확장** — `semantic_constraints` optional param + final override block append helper.
3. **scene_image_pipeline.generate_and_validate_scene() pass-through** — caller wiring 1 곳 만.
4. **신규 prompt pack** — `prompts/_base/prompt_sanitizer/2.202605112104/` 에 `sanitize_user.md` (slot 추가) + `sanitize_system.md` (v1 그대로 복사).
5. **Tests 4 개** — router 2, sanitizer 1, pipeline integration 1.

### 2.2 Out of scope (이번 patch 안 함)

한 줄씩:

- **B-next (즉시 후속)**: `shot_staging` schema 에 `subject_state.immobility_state` enum 신규 (cryosleep / fainted / coma / vegetative_state / restrained / paralyzed / sedated / asleep 등 LLM 분류). router 의 primary SOT 가 이 field 로 전환.
- **scene_generation_coordinator fallback sanitize wiring** (`scene_generation_coordinator.py:1462+`). primary sanitizer 는 `generate_and_validate_scene()` 내부라 1 곳 wiring 으로 정상 경로 cover. fallback 은 후속.
- **ref / bg sanitize wiring** — `reference_image_generator._generate_reference_image:352`, `ref_image_pipeline._generate_ref_image:255`, `background_chain_render._sanitize_and_retry:279`. router 입력 (staging / card / visible) 자체가 ref/bg context 라 별도 rule 필요.
- **post-check / LVM gate / LLM judge / text regex fallback** — 영구 제외.
- **Future tags** — `photo_orientation` (Patch C) / `mobile_fixture` (Patch D) / `prop_binding` (Patch E 부차). `SemanticContract.tags` field 가 hook 만 보존, 본 patch 에서 활성 0.
- **version_registry bump** — `prompt_sanitizer/v1 → /2` registry 이름 갱신 안 함. prompt_loader 가 file pack numeric-sort 로 latest 자동 선택. `PROMPT_VERSION_PACK_STRICT` 환경에서도 drift 방지를 위해 v2 pack 에 두 stem (sanitize_system / sanitize_user) 모두 둠. registry 정리는 후속 관측성 patch.
- **coordinator:1459 `sanitization_note` ↔ schema `changes` alias mismatch** — silent observability gap, 본 patch 와 무관. 별도.

### 2.3 시나리오 의존성 0

- production grep gate: 5 site (`semantic_contract_router.py` / `prompt_sanitizer.py` / `scene_image_pipeline.py` / `scene_generation_coordinator.py` caller wiring 신규 line / `prompts/_base/prompt_sanitizer/2.202605112104/*`) 에서 작품 고유명사 (수리영 / 혜수 / 민숙 / 인우 / 금월도 / 두 소녀 / 옥탑방 / 조타실 / 갑판 등) 0 hit.
- test fixture: synthetic identifier 직접 사용 (`Subject Alpha` / `Subject Beta` / `C91` / `C92`). 별도 gate 불필요.

### 2.4 AC (Automated)

이번 patch 자동 verification 의무 (image visual QA 는 manual, 아래 4 항목만 자동):

1. `sanitize(... semantic_constraints={mode='immobilized', ...})` 호출 후 `result["sanitized_prompt"]` 에 `SEMANTIC OVERRIDE:` 문자열 포함.
2. block 안에 affected entity_id + source_state line 존재 (예: `- C91 (dead): ...`).
3. block 위치 = strategy prefix **뒤** (final position — `prompt.rstrip() + "\n\n" + block`).
4. `generate_and_validate_scene(... semantic_constraints={...})` 가 Gemini moderation block 발생 시 sanitizer.sanitize 호출에 동일 constraints 를 propagate. mock 으로 call_args 검증.

manual visual QA (Patch A canary idiom): S11/14·S11/20 단건 redo-shot 호출 후 PNG 시각 검증 — 시신/포즈락 prop 의 자세 무결성 유지.

---

## 3. Contract Router

### 3.1 위치

`backend/app/modules/semantic_contract_router.py` (신규, modules/ 최상위 — sanitize 외 영역 확장 cover).

### 3.2 SemanticContract dataclass

```python
from dataclasses import dataclass
from typing import Any, Optional

SEMANTIC_PRIMARY_MODES: tuple[str, ...] = ("none", "pose_locked", "immobilized")
IMMOBILIZED_GAZE: frozenset[str] = frozenset({"dead", "unconscious", "severely_injured"})


@dataclass(frozen=True)
class SemanticContract:
    primary_mode: str                                # SEMANTIC_PRIMARY_MODES 중 하나
    tags: tuple[str, ...]                            # 미래 비배타 mode 누적 hook. B-min 빈 tuple.
    pose_locked_entity_ids: tuple[str, ...]          # 정렬된 C## 목록
    immobilized_entity_ids: tuple[str, ...]          # ⊆ pose_locked
    source_states: tuple[tuple[str, str], ...]       # (("C91", "dead"), ...) — deterministic order
    evidence: tuple[dict[str, Any], ...]             # ({"source", "entity_id", "value"}, ...)
    sanitizer_constraints: dict[str, Any] | None     # primary_mode=="none" 면 None
```

`tags` 는 미래 patch (C/D/E) 가 hook 으로 사용. B-min 항상 빈 tuple.

### 3.3 build_semantic_contract()

signature:
```python
def build_semantic_contract(
    *,
    shot_staging: dict | None,
    render_prompt_card: dict | None,
    visible_entities: list[dict[str, Any]],
) -> SemanticContract: ...
```

#### Rule 1 — Immobilized (gaze_target SOT)

```python
name_to_sid = {
    e["name"]: e["short_id"]
    for e in (visible_entities or [])
    if e.get("entity_type") == "character" and e.get("name") and e.get("short_id")
}

immobilized: set[str] = set()
source_state_map: dict[str, str] = {}
evidence_list: list[dict] = []

for entry in (shot_staging or {}).get("character_angles", []) or []:
    gaze = entry.get("gaze_target")
    if gaze not in IMMOBILIZED_GAZE:
        continue
    sid = name_to_sid.get(entry.get("character") or "")
    if not sid:
        continue
    immobilized.add(sid)
    source_state_map[sid] = gaze
    evidence_list.append({
        "source": "shot_staging.character_angles.gaze_target",
        "entity_id": sid,
        "value": gaze,
    })
```

#### Rule 2 — Pose locked (fixed_elements SOT, 과탐 방지)

```python
fixed_elements = (
    (render_prompt_card or {})
    .get("continuity_elements_used", {})
    .get("fixed_elements", []) or []
)
pose_locked: set[str] = set()

for element in fixed_elements:
    if element.get("element_type") != "character_state":
        continue
    char_name = element.get("character_name") or ""             # 단일 필드 (schema 확인됨, schema.json:25)
    sid = name_to_sid.get(char_name)
    if sid and sid not in immobilized:
        pose_locked.add(sid)
        evidence_list.append({
            "source": "render_prompt_card.continuity_elements_used.fixed_elements.character_name",
            "entity_id": sid,
            "value": element.get("element_id") or "character_state",
        })
```

주의: `character_state` 는 sleep / rest / injury / death 를 모두 포함하는 넓은 카테고리 (`scene_consistency/6.202605031033/schema.json:21`). 이것만으로 alive-rewrite 금지까지 발동하면 과탐 — pose 만 lock, state polarity 단정 없음.

#### 결합 및 contract emission

```python
primary_mode = "none"
if immobilized:
    primary_mode = "immobilized"
elif pose_locked:
    primary_mode = "pose_locked"

pose_locked.update(immobilized)                                 # immobilized ⊆ pose_locked invariant

pose_locked_ids = tuple(sorted(pose_locked))
contract = SemanticContract(
    primary_mode=primary_mode,
    tags=(),
    pose_locked_entity_ids=pose_locked_ids,
    immobilized_entity_ids=tuple(sorted(immobilized)),
    source_states=tuple(sorted(source_state_map.items())),
    evidence=tuple(evidence_list),
    sanitizer_constraints=_build_sanitizer_constraints(             # 3.4 private helper
        primary_mode, pose_locked_ids, source_state_map, tuple(evidence_list),
    ),
)
```

### 3.4 sanitizer_constraints 산출 (build 시점 1회만, SOT 단일화)

`build_semantic_contract()` 내부에서 final emission 직전 `_build_sanitizer_constraints()` private helper 로 1 회 계산 → dataclass `sanitizer_constraints` field 에 frozen. caller 는 `contract.sanitizer_constraints` 직접 access (별도 변환 helper 없음 — 이중 SOT 차단).

```python
def _build_sanitizer_constraints(
    primary_mode: str,
    pose_locked_entity_ids: tuple[str, ...],
    source_state_map: dict[str, str],
    evidence: tuple[dict[str, Any], ...],
) -> dict | None:
    if primary_mode == "none":
        return None
    constraints: dict[str, Any] = {
        "semantic_mode": primary_mode,
        "entity_ids": list(pose_locked_entity_ids),
        "source_states": dict(source_state_map),
        "preserve_pose": True,
        "override_strategy_prefix": True,
        "evidence": [dict(e) for e in evidence],
    }
    if primary_mode == "immobilized":
        constraints.update(
            preserve_subject_state=True,
            forbid_state_polarity_rewrite=True,
            forbid_unharmed_rewrite=True,
            forbid_active_reaction=True,
        )
    else:                                                       # pose_locked
        constraints.update(
            preserve_subject_state=False,
            forbid_state_polarity_rewrite=False,                # character_state 과탐 방지
            forbid_unharmed_rewrite=False,
            forbid_active_reaction=False,
        )
    return constraints
```

caller usage 예:
```python
contract = build_semantic_contract(...)
semantic_constraints = contract.sanitizer_constraints           # 직접 access, 별도 변환 없음
```

### 3.5 LLM ↔ Router 책임 경계

- **LLM**: upstream step (shot_staging — 현재 / subject_state — B-next) 에서 *상태 분류만* 자유 텍스트 아닌 enum 으로 출력.
- **Router**: enum 값을 *해석 안 하고 읽기만* — `gaze in IMMOBILIZED_GAZE` 같은 deterministic mapping.
- **Sanitizer**: router 의 `constraints` 를 user 메시지 + final block 으로 *injection 만*. polarity 보존 지시.

---

## 4. Sanitizer Integration

### 4.1 PromptSanitizer.sanitize() signature

```python
def sanitize(
    self,
    original_prompt: str,
    block_reason: str,
    block_categories: List[str],
    attempt: int = 1,
    semantic_constraints: Optional[Dict[str, Any]] = None,      # 신규
) -> Dict[str, Any]: ...
```

`semantic_constraints` is None → 기존 동작 그대로. None 아니면 2 layer 처리:

1. `_render_semantic_constraints_block(semantic_constraints)` → user.md `{semantic_constraints_block}` slot 에 렌더.
2. LLM 호출 + 결과 받음 + strategy prefix prepend (line 133, 변경 없음).
3. `_apply_semantic_override(result["sanitized_prompt"], semantic_constraints)` → final prompt 끝에 deterministic block append.

### 4.2 _render_semantic_constraints_block

```python
def _render_semantic_constraints_block(constraints: Optional[dict]) -> str:
    """user.md {semantic_constraints_block} slot 렌더. constraints=None → 빈 문자열."""
    if not constraints:
        return ""
    lines = [
        "SEMANTIC CONSTRAINTS (이 섹션이 전략 프리픽스와 충돌 시 우선합니다):",
        f"- semantic_mode: {constraints['semantic_mode']}",
    ]
    if constraints.get("source_states"):
        lines.append("- 보존 대상 entity 와 상태:")
        for sid, state in sorted(constraints["source_states"].items()):
            lines.append(f"  - {sid}: {state}")
    for key in (
        "preserve_pose", "preserve_subject_state",
        "forbid_state_polarity_rewrite", "forbid_unharmed_rewrite",
        "forbid_active_reaction",
    ):
        if key in constraints:
            lines.append(f"- {key}: {constraints[key]}")
    return "\n".join(lines)
```

### 4.3 _apply_semantic_override (state-aware per-entity)

```python
_STATE_GUIDANCE: dict[str, str] = {
    "dead":             "Do not rewrite as alive, unharmed, asleep, or emotionally recovering.",
    "unconscious":      "Do not rewrite as conscious, reacting, emotionally processing, or standing.",
    "severely_injured": "Do not rewrite as unharmed, recovered, active, or standing/walking.",
    # B-next 확장: cryosleep / asleep / fainted / coma / vegetative_state / restrained / paralyzed / sedated.
    # asleep 은 B-next 에서 legit immobility state — 공통 forbidden list 에 박지 않음.
}

_POSE_LOCKED_BLOCK = (
    "SEMANTIC OVERRIDE:\n"
    "Preserve the fixed pose and body position for affected subjects.\n"
    "Do not introduce a new gesture, standing pose, walking pose, active reaction, or changed limb position.\n"
    "This override takes priority over the sanitizer strategy prefix.\n"
    "Affected entities: {entity_list}."
)


def _build_immobilized_block(constraints: dict) -> str:
    source_states = constraints.get("source_states", {})
    lines = [
        "SEMANTIC OVERRIDE:",
        "Preserve each affected subject's immobilized state and fixed posture.",
        "This override takes priority over the sanitizer strategy prefix.",
        "",
        "Per-entity state contracts:",
    ]
    for sid in sorted(source_states):
        state = source_states[sid]
        guidance = _STATE_GUIDANCE.get(state, "Preserve the structured source state; do not rewrite.")
        lines.append(f"- {sid} ({state}): {guidance}")
    return "\n".join(lines)


_SEMANTIC_OVERRIDE_MARKER = "SEMANTIC OVERRIDE:"


def _apply_semantic_override(prompt: str, constraints: Optional[dict]) -> str:
    """final sanitized_prompt 끝에 deterministic SEMANTIC OVERRIDE block append.

    중복 append 차단: scene_image_pipeline 의 moderation retry 루프 (line 261)
    는 `current_prompt = sanitize_result.get("sanitized_prompt", ...)` 로 갱신
    하므로, 다음 retry 의 sanitize() 입력 prompt 에 이미 override block 이 포함
    돼 있다. exact literal marker 기반 검색 (의미 판단 아님) 으로 기존 block
    tail 을 잘라낸 후 새 block append — retry 마다 1 block 만 유지.
    """
    if not constraints or not constraints.get("override_strategy_prefix"):
        return prompt
    mode = constraints.get("semantic_mode")
    if mode == "immobilized":
        block = _build_immobilized_block(constraints)
    elif mode == "pose_locked":
        entity_list = ", ".join(constraints.get("entity_ids", [])) or "(none)"
        block = _POSE_LOCKED_BLOCK.format(entity_list=entity_list)
    else:
        return prompt

    idx = prompt.find(_SEMANTIC_OVERRIDE_MARKER)
    if idx >= 0:
        head = prompt[:idx].rstrip()
        return head + "\n\n" + block
    return prompt.rstrip() + "\n\n" + block
```

### 4.4 sanitize() body (변경 부분)

기존 line 113~146 영역:

```python
system_prompt = _load_prompt("sanitize_system.md")
user_prompt = _load_prompt("sanitize_user.md").format(
    original_prompt=original_prompt,
    block_reason=block_reason,
    block_categories=", ".join(block_categories) if block_categories else "N/A",
    attempt=attempt,
    strategy_name=strategy["name"],
    strategy_description=strategy["description"],
    strategy_prefix=strategy["prefix"],
    semantic_constraints_block=_render_semantic_constraints_block(semantic_constraints),   # 신규
)

result = call_structured(...)                                                              # 변경 없음

sanitized = result.get("sanitized_prompt", "")
if not sanitized.startswith(strategy["prefix"].strip()[:40]):
    sanitized = strategy["prefix"] + sanitized                                             # 기존 prepend 보존

sanitized = _apply_semantic_override(sanitized, semantic_constraints)                       # 신규 append
result["sanitized_prompt"] = sanitized
result["strategy"] = strategy_key

logger.info(...)                                                                            # 변경 없음
return result
```

### 4.5 Prompt pack — `prompts/_base/prompt_sanitizer/2.202605112104/`

두 stem 모두 신규 dir 에 둠 (prompt_loader pack drift 방지).

**`sanitize_system.md`** — v1 그대로 복사, 본문 변경 0.

**`sanitize_user.md`** — v1 본문 + `{semantic_constraints_block}` slot 1 줄 추가:

```
원본 프롬프트:
{original_prompt}

거절 사유: {block_reason}
거절 카테고리: {block_categories}
수정 시도: {attempt}차

적용 전략: {strategy_name} — {strategy_description}
전략 프리픽스:
{strategy_prefix}

{semantic_constraints_block}

위 전략의 프리픽스를 포함하여, 원본 프롬프트를 T2I 안전 정책을 충족하도록 포토리얼리스틱 스타일로 수정해주세요.
```

slot 위치: 전략 프리픽스 다음, 최종 지시 직전. constraints=None 일 때 `_render_semantic_constraints_block` 가 빈 문자열 반환 → format 결과에 단순 빈 line 1개 (LLM 무영향).

### 4.6 version_registry bump 정책

이번 patch 에서 `version_registry.py` `prompt_sanitizer` entry 갱신 안 함. 근거:
- `PromptSanitizer._load_prompt` 는 `load_prompt(module, stem)` 만 호출 (`prompt_sanitizer.py:78`). DB 미전달 file-only path.
- `prompt_loader` 가 file pack numeric-sort 로 latest 자동 선택. registry 단축 표기 (`prompt_sanitizer/v1`) 가 strict mode 아닐 때 영향 없음.
- `PROMPT_VERSION_PACK_STRICT=true` 환경에서도 drift 방지를 위해 v2 pack 에 sanitize_system + sanitize_user 두 stem 모두 둔다 (4.5 참조).
- registry entry 정리 (`"prompt_sanitizer": "2.0.0"` + `"prompt_dependency": "prompt_sanitizer/2"`) 는 별도 관측성 patch.

---

## 5. Caller Wiring (1 site 만)

### 5.1 scene_image_pipeline.generate_and_validate_scene()

위치: `backend/app/modules/pipeline/scene_image_pipeline.py:204+`.

signature 확장:
```python
def generate_and_validate_scene(
    gemini_client: GeminiImageClient,
    t2i_prompt: str,
    beat_title: str,
    output_dir: Path,
    reference_images: Optional[List[Tuple[str, bytes]]] = None,
    previous_scene_bytes: Optional[bytes] = None,
    trace_meta: Optional[Dict[str, Any]] = None,
    semantic_constraints: Optional[Dict[str, Any]] = None,      # 신규
) -> Dict[str, Any]: ...
```

line 260 sanitize 호출 propagation:
```python
sanitize_result = sanitizer.sanitize(
    current_prompt, exc.block_reason, exc.block_categories,
    attempt=attempt+1,
    semantic_constraints=semantic_constraints,                  # 신규
)
```

### 5.2 호출 site

`scene_image_pipeline.generate_and_validate_scene()` 의 caller (현재 `scene_generation_coordinator.py` shot loop) 가 `semantic_constraints` 를 build 해서 전달:

```python
from app.modules.semantic_contract_router import build_semantic_contract

# shot 단위 build
contract = build_semantic_contract(
    shot_staging=_shot_staging_for_index(staging_map, scene_index, shot_index),
    render_prompt_card=still_data.get("render_prompt_card"),
    visible_entities=visible_entities,
)
semantic_constraints = contract.sanitizer_constraints           # 직접 access (3.4 helper SOT 단일화)

# pipeline 호출
pipe_result = generate_and_validate_scene(
    gemini_client=...,
    t2i_prompt=...,
    ...,
    semantic_constraints=semantic_constraints,
)
```

`staging_map / visible_entities` 는 coordinator 가 이미 shot loop 안에서 가지고 있음 (기존 state variant detection path, `scene_reference_service.py:996` 와 동일 데이터 소스).

### 5.3 미연결 site (out of scope)

- `scene_generation_coordinator.py:1462+` fallback sanitize — 본 patch 안 함. primary sanitize (generate_and_validate_scene 내부) 가 정상 경로 cover.
- `reference_image_generator._generate_reference_image:352`
- `ref_image_pipeline._generate_ref_image:255`
- `background_chain_render._sanitize_and_retry:279`

위 4 곳은 후속 patch.

---

## 6. Tests

### 6.1 4 개 자동 test (synthetic fixture, 시나리오 의존 0)

#### Test 1 — `tests/unit/test_semantic_contract_router.py::test_gaze_target_dead_produces_immobilized`

```python
def test_gaze_target_dead_produces_immobilized():
    staging = {"character_angles": [{"character": "Subject Alpha", "gaze_target": "dead"}]}
    visible = [{"name": "Subject Alpha", "short_id": "C91", "entity_type": "character"}]

    contract = build_semantic_contract(
        shot_staging=staging,
        render_prompt_card=None,
        visible_entities=visible,
    )

    assert contract.primary_mode == "immobilized"
    assert contract.immobilized_entity_ids == ("C91",)
    assert contract.pose_locked_entity_ids == ("C91",)             # immobilized ⊆ pose_locked invariant
    assert contract.source_states == (("C91", "dead"),)
    assert contract.sanitizer_constraints["forbid_state_polarity_rewrite"] is True
    assert contract.sanitizer_constraints["override_strategy_prefix"] is True
```

#### Test 2 — `tests/unit/test_semantic_contract_router.py::test_character_state_only_produces_pose_locked`

```python
def test_character_state_only_produces_pose_locked():
    card = {
        "continuity_elements_used": {
            "fixed_elements": [
                {"element_type": "character_state", "character_name": "Subject Beta", "element_id": "char_state_seated"},
            ],
        },
    }
    visible = [{"name": "Subject Beta", "short_id": "C92", "entity_type": "character"}]

    contract = build_semantic_contract(
        shot_staging=None,
        render_prompt_card=card,
        visible_entities=visible,
    )

    assert contract.primary_mode == "pose_locked"
    assert contract.pose_locked_entity_ids == ("C92",)
    assert contract.immobilized_entity_ids == ()
    assert contract.source_states == ()
    constraints = contract.sanitizer_constraints
    assert constraints["preserve_pose"] is True
    assert constraints["forbid_state_polarity_rewrite"] is False   # 과탐 방지
    assert constraints["forbid_unharmed_rewrite"] is False
    assert constraints["override_strategy_prefix"] is True         # weaker block 도 final 에 넣음
```

#### Test 3 — `tests/unit/test_prompt_sanitizer_constraints.py::test_sanitize_two_layer_defense`

LLM `call_structured` mock 필요 (`prompt_sanitizer.py` 의 `call_structured` import):

2-layer defense (user prompt SEMANTIC CONSTRAINTS 섹션 + final SEMANTIC OVERRIDE block) 둘 다 검증.

```python
def test_sanitize_two_layer_defense(monkeypatch):
    captured_kwargs: dict = {}

    def fake_call_structured(**kwargs):
        captured_kwargs.update(kwargs)
        return {
            "sanitized_prompt": "safe rewritten prompt body",
            "changes": "removed unsafe terms",
            "strategy": "film_previs",
        }
    monkeypatch.setattr(
        "app.modules.llm.llm_client.call_structured",
        fake_call_structured,
    )

    sanitizer = PromptSanitizer()
    constraints = {
        "semantic_mode": "immobilized",
        "entity_ids": ["C91"],
        "source_states": {"C91": "dead"},
        "preserve_pose": True,
        "preserve_subject_state": True,
        "forbid_state_polarity_rewrite": True,
        "forbid_unharmed_rewrite": True,
        "forbid_active_reaction": True,
        "override_strategy_prefix": True,
        "evidence": [],
    }

    result = sanitizer.sanitize(
        original_prompt="unsafe original",
        block_reason="SAFETY",
        block_categories=["violence"],
        attempt=1,
        semantic_constraints=constraints,
    )

    # Layer 1 — user prompt 에 SEMANTIC CONSTRAINTS 섹션 포함 + entity/state line
    user_prompt = captured_kwargs["user_prompt"]
    assert "SEMANTIC CONSTRAINTS" in user_prompt
    assert "C91" in user_prompt
    assert "dead" in user_prompt
    assert "forbid_state_polarity_rewrite: True" in user_prompt

    # Layer 2 — final sanitized_prompt 에 SEMANTIC OVERRIDE block append
    sanitized = result["sanitized_prompt"]
    assert "SEMANTIC OVERRIDE:" in sanitized
    assert "C91 (dead):" in sanitized

    # block 위치 = strategy prefix 뒤 (final position)
    prefix_marker = "pre-visualization concept art"                # film_previs prefix substring
    assert sanitized.index(prefix_marker) < sanitized.index("SEMANTIC OVERRIDE:")

    # 중복 append 차단 검증 (retry 입력 시뮬레이션)
    result2 = sanitizer.sanitize(
        original_prompt=sanitized,                                  # 이전 retry 의 output 을 다음 입력으로
        block_reason="SAFETY",
        block_categories=["violence"],
        attempt=2,
        semantic_constraints=constraints,
    )
    assert result2["sanitized_prompt"].count("SEMANTIC OVERRIDE:") == 1
```

#### Test 4 — `tests/integration/test_scene_image_pipeline_immobilized.py::test_pipeline_propagates_constraints_to_sanitizer`

PNG metadata embed + LVM 호출은 본 patch 무관 — monkeypatch 로 no-op 처리하여 pipeline 골격 그대로 통과시킴.

```python
def test_pipeline_propagates_constraints_to_sanitizer(tmp_path, monkeypatch):
    captured_calls: list[dict] = []

    class FakeSanitizer:
        def sanitize(self, *args, **kwargs):
            captured_calls.append(kwargs)
            return {
                "sanitized_prompt": "sanitized output",
                "changes": "removed",
                "strategy": "film_previs",
            }

    class FakeGeminiClient:
        _attempt = 0
        def set_context(self, **kw): pass
        def generate_image(self, prompt, labeled_references=None, aspect_ratio=None):
            self._attempt += 1
            if self._attempt == 1:
                raise ModerationError(block_reason="SAFETY", block_categories=["violence"])
            # 1x1 PNG — embed_png_metadata 를 monkeypatch 하므로 dummy bytes 도 안전
            return (b"\x89PNG\r\n\x1a\n_dummy_png_bytes_for_test_", None)

    # PromptSanitizer 클래스 자체를 fake 로 교체
    monkeypatch.setattr(
        "app.modules.pipeline.scene_image_pipeline.PromptSanitizer",
        lambda *a, **kw: FakeSanitizer(),
    )
    # PNG metadata embed 우회 (본 patch 무관)
    monkeypatch.setattr(
        "app.modules.pipeline.scene_image_pipeline.embed_png_metadata",
        lambda *a, **kw: None,
    )
    # LVM validation skip (본 patch 무관)
    monkeypatch.setattr(
        "app.modules.pipeline.scene_image_pipeline._decide_scene_lvm",
        lambda *a, **kw: (False, "test_skip"),
    )

    constraints = {
        "semantic_mode": "immobilized",
        "entity_ids": ["C91"],
        "source_states": {"C91": "dead"},
        "override_strategy_prefix": True,
        "preserve_pose": True,
        "preserve_subject_state": True,
        "forbid_state_polarity_rewrite": True,
        "forbid_unharmed_rewrite": True,
        "forbid_active_reaction": True,
        "evidence": [],
    }

    generate_and_validate_scene(
        gemini_client=FakeGeminiClient(),
        t2i_prompt="prompt",
        beat_title="beat",
        output_dir=tmp_path,
        semantic_constraints=constraints,
    )

    assert len(captured_calls) == 1
    assert captured_calls[0]["semantic_constraints"] == constraints
    assert captured_calls[0]["attempt"] == 2                       # moderation 1차 fail → sanitize attempt=2
```

### 6.2 Broader regression

- `pytest backend/tests/test_moderation*.py` — sanitize 기존 동작 변경 없는지 (semantic_constraints=None default).
- `pytest backend/tests/pipeline/` — pipeline 무변경.

### 6.3 Production grep gate

implementation merge 전 production 파일 5개 (caller 포함) 에 시나리오 token zero-hit 확인:

```bash
grep -rE '수리영|혜수|민숙|인우|금월도|두 소녀|옥탑방|조타실|갑판' \
  backend/app/modules/semantic_contract_router.py \
  backend/app/modules/prompt_sanitizer.py \
  backend/app/modules/pipeline/scene_image_pipeline.py \
  backend/app/services/scene_generation_coordinator.py \
  prompts/_base/prompt_sanitizer/2.202605112104/
```

Expected: 0 hit. (coordinator 는 본 patch 의 build_semantic_contract 호출 line 만 신규로 들어가므로 시나리오 token 영향 없음 — 가드 의무화.)

test fixture 는 별도 gate 불필요 (synthetic `Subject Alpha` / `C91` 직접 사용).

### 6.4 Manual visual QA (out of automated AC)

patch 적용 + production canary (PID `02829fe8` ep1) 에서 S11/14 + S11/20 단건 redo-shot 호출 → 생성된 sanitized_prompt 에 `SEMANTIC OVERRIDE:` block 존재 확인 + 생성된 PNG 의 시신/포즈락 prop 자세 무결성 사용자 시각 검증.

---

## 7. Out of Scope

이번 patch 영역 밖 (한 문단):

**B-next** = `shot_staging` schema 에 `subject_state.immobility_state` enum (cryosleep / fainted / coma / vegetative_state / restrained / paralyzed / sedated / asleep / dead / severely_injured / none) 신규 + LLM 분류 + router primary SOT 전환. **coordinator fallback sanitize wiring + ref/bg sanitize wiring** = 별도 후속 patch. **post-check / LVM gate / LLM judge / text regex fallback** = 영구 제외. **Future tags** (photo_orientation / mobile_fixture / prop_binding 등 Patch C/D/E) = `SemanticContract.tags` 가 hook 만 보존, 본 patch 에서 활성 0. **version_registry bump + coordinator:1459 alias mismatch** = 별도 관측성 patch. **prop state SOT** = `gaze_target` 은 character only — 시신 prop / 파손 prop / mobile_fixture 상태는 별도 schema.

---

## 8. 변경 파일 요약

이번 patch 가 건드리는 production 파일:

1. `backend/app/modules/semantic_contract_router.py` — 신규
2. `backend/app/modules/prompt_sanitizer.py` — signature 확장 + 2 helper
3. `backend/app/modules/pipeline/scene_image_pipeline.py` — `generate_and_validate_scene()` signature + propagation
4. `prompts/_base/prompt_sanitizer/2.202605112104/sanitize_user.md` — 신규 dir, slot 추가
5. `prompts/_base/prompt_sanitizer/2.202605112104/sanitize_system.md` — 신규 dir, v1 그대로 복사

caller wiring (semantic_constraints build + 전달):
- `backend/app/services/scene_generation_coordinator.py` — `build_semantic_contract` import + shot loop 안 1 곳 build + `generate_and_validate_scene(... semantic_constraints=...)` 인자 전달.

test 파일:
- `backend/tests/unit/test_semantic_contract_router.py` — 신규 (Test 1, 2)
- `backend/tests/unit/test_prompt_sanitizer_constraints.py` — 신규 (Test 3)
- `backend/tests/integration/test_scene_image_pipeline_immobilized.py` — 신규 (Test 4)

총 production 파일 5 + caller 1 + test 3 = **9 파일**.

---

## 9. Spec Self-Review

### 9.1 Placeholder scan

- "TBD" / "TODO" / 빈 섹션: 없음.
- 모호한 토큰 (`...` 외 의미 placeholder): 없음.
- prompt pack timestamp `2.202605112104` = 현재 작성 시각 fixed. 구현 시점에 재생성 가능.

### 9.2 Internal consistency

- §1.3 architectural orientation (LLM SOT + 2 layer defense + no post-check) ↔ §3.5 책임 경계 ↔ §4.1~4.4 sanitize() body ↔ §6.1 tests — 모두 정합.
- §2.1 in scope 4 production files ↔ §8 변경 파일 요약 5 (prompt pack 2 stem 별도 count) ↔ §5.1~5.2 wiring 1 site — 정합.
- `immobilized ⊆ pose_locked` invariant — §3.3 결합 단계 + §6.1 Test 2 (pose_locked 만 단독) 정합.

### 9.3 Scope check

B-min focused — Codex review 의 5 reduction 모두 반영. ~700 lines, Patch A 의 1/3.

### 9.4 Ambiguity check

- "override_strategy_prefix" — pose_locked 도 True (§3.4 의 결정). 명시됨.
- `version_registry` 정책 — §4.6 에 조건 명시 (PROMPT_VERSION_PACK_STRICT 환경에서도 v2 pack 두 stem 으로 drift 방지).
- AC = 자동 4 항목 (§2.4) + manual visual QA (§6.4) — 명확 분리.

---

## 10. Patch A 와의 관계

본 patch 는 `2026-05-11-visual-qa-story-prop-binding-plan.md` §1.2 의 Patch B 영역. Patch A 와 영역 겹침 없음:

- Patch A: RPC contract (`build_asset_requirements` prop kind / `build_id_policy` common_noun split / validator 3-tier).
- Patch B (본 patch): sanitize LLM 호출 시 semantic constraint injection.

상호 의존성 없음. Patch A `f31dfa5` 의 shot-level retry + redo-shot endpoint 는 본 patch 운영 검증 시 단건 재시도 도구로 활용.

---

## 11. Codex Review 의무

implementation merge 전 Codex inline review:
- BLOCKING + IMPORTANT + MINOR 분류
- BLOCKING 0 까지 iterate
- broader regression 회귀 0 보장 (목표: 기존 sanitize behavior 변경 0, 신규 path 만 추가)
- 본 spec 자체에 대한 Codex review 5 round 진행됨 (architecture + 1~3, 4, 5~7, slim consolidation, final).

---

End of spec.
