# Patch C — Flat Directional Element Orientation Contract Implementation Plan

> ⚠️ **SUPERSEDED / DISCARDED (2026-05-12)** — 본 plan v1.0 은 폐기된 v1.0/v1.1 spec (flat_directional_classifier noun-list 접근) 의 implementation. 이미 Task 1 commit (`8d7d195`) → revert (`c631231`) 으로 코드 0. spec 은 v1.3 까지 진화 후 다시 SUPERSEDED, [`2026-05-12-llm-structured-sot-migration-design.md`](../specs/2026-05-12-llm-structured-sot-migration-design.md) 의 Area A 로 흡수됨. **새 implementation plan** = `2026-05-12-patch-c-directionality-sot-implementation.md`. 본 파일은 history 용도로만 carry.

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** S26/6 같은 평면 held / mounted 객체 (photo / letter / poster / screen / painting 등) 의 `orientation` SOT 를 shot_staging 안에 강제하고, scene_detail t2i_prompt 에 강한 영어 directive 로 inject 해 T2I 모델이 사진 뒷면 렌더링하는 결함 차단.

**Architecture:** 3-tier defense (Patch A 패턴 carry) — Producer (shot_staging prompt v9 + per-batch validator + retry) → Shared helper (flat_directional_classifier 모듈) → Consumer (detail_steps.py 강한 영어 directive inject). 공용 helper 가 producer / consumer 양쪽에서 import 되어 카테고리 / empty 판정 drift 0 보장. 3D fixture (monitor / mirror / door / window / TV) 는 unchanged — scope guard.

**Tech Stack:** Python 3.12 / FastAPI / pytest / Gemini structured output (shot_staging) / Patch A 패턴.

**Spec:** `docs/superpowers/specs/2026-05-12-patch-c-photo-orientation-design.md` v1.1 (commit `a2c1f15`).

---

## File Structure

**Create (3):**
- `backend/app/modules/pipeline/flat_directional_classifier.py` — pure helper module, producer / consumer 공용.
- `prompts/_base/shot_staging/9.202605121200/schema.json` — orientation description 확장.
- `prompts/_base/shot_staging/9.202605121200/system.md` — flat held 객체 directional 작성 강제 가이드.

**Modify (3):**
- `backend/app/core/errors.py` — `ShotStagingOrientationError(AppError)` 신규 클래스.
- `backend/app/modules/pipeline/shot_staging.py` — batch loop 안 per-attempt validator + retry. `MAX_ATTEMPTS=3`. validator raise 는 broad `except Exception` 밖.
- `backend/app/core/steps/detail_steps.py` — L2128-L2136 inline bg_lines builder 를 `_build_bg_element_line` helper 호출로 치환.

**Test files (3):**
- `backend/tests/pipeline/test_flat_directional_classifier.py` — pure helper 테스트 (G1-G12).
- `backend/tests/pipeline/test_shot_staging_orientation_validator.py` — call_structured mock 테스트 (G13-G19).
- `backend/tests/core/steps/test_detail_steps_bg_element_line.py` — helper 단위 테스트 (G20-G25).

**Unchanged (verify):**
- `backend/app/core/steps/render_prompt_card.py` — `prop_orientation_rule` constraint 그대로. golden hash 영향 0.
- `prompts/_base/shot_staging/8.202604201230/` — archive 로 남김 (삭제 금지 — prompt 버전 디렉토리 정책).

---

## Task 1: flat_directional_classifier 모듈 — 카테고리 list + 매칭 helper

**Files:**
- Create: `backend/app/modules/pipeline/flat_directional_classifier.py`
- Test: `backend/tests/pipeline/test_flat_directional_classifier.py`

### - [ ] Step 1: 실패 테스트 작성

Create `backend/tests/pipeline/test_flat_directional_classifier.py`:

```python
"""flat_directional_classifier helper tests — G1~G12 (spec §8.1).

Patch C — Pure helper 테스트. DB / HTTP / LLM 호출 없음.
"""
import pytest

from app.modules.pipeline.flat_directional_classifier import (
    is_flat_directional,
    is_orientation_empty,
    directive_for,
)


# ── G1: is_flat_directional positive (영어) ────────────────────────────────
@pytest.mark.parametrize(
    "element",
    [
        "photo",
        "an old photograph",
        "letter",
        "a folded document",
        "map",
        "poster on the wall",
        "screen",
        "painting",
        "drawing",
        "small sign",
        "playing card",
        "a torn page",
        "thin paper",
        "old picture",
    ],
)
def test_is_flat_directional_positive_english(element):
    assert is_flat_directional(element) is True


# ── G2: is_flat_directional positive (한국어) ──────────────────────────────
@pytest.mark.parametrize(
    "element",
    [
        "사진",
        "낡은 액자",
        "흰 종이",
        "그림",
        "큰 화면",
        "표지판",
        "지도",
        "편지",
        "포스터",
        "카드",
        "낡은 문서",
    ],
)
def test_is_flat_directional_positive_korean(element):
    assert is_flat_directional(element) is True


# ── G3: is_flat_directional negative (3D fixture — scope guard) ──────────
@pytest.mark.parametrize(
    "element",
    [
        "monitor",
        "old mirror",
        "wooden door",
        "open window",
        "TV",
        "tv",
        "television",  # 3D fixture, flat 아님
    ],
)
def test_is_flat_directional_negative_3d_fixture(element):
    assert is_flat_directional(element) is False


# ── G4: is_flat_directional negative (non-directional surface) ────────────
@pytest.mark.parametrize(
    "element",
    [
        "wooden deck",
        "rain-slick wall",
        "ceiling",
        "fishing boat deck",
        "concrete floor",
        "",
    ],
)
def test_is_flat_directional_negative_nondirectional(element):
    assert is_flat_directional(element) is False


# ── G5: frame false-positive deny ─────────────────────────────────────────
@pytest.mark.parametrize(
    "element",
    [
        "camera frame",
        "frame edge",
        "frame line",
        "image frame edge",  # "image" 미포함, "frame" deny → False
    ],
)
def test_is_flat_directional_frame_false_positive_deny(element):
    assert is_flat_directional(element) is False


# ── G6: frame positive (복합어로 다른 flat noun 매칭) ────────────────────
@pytest.mark.parametrize(
    "element",
    [
        "picture frame",        # via 'picture'
        "photo frame",          # via 'photo'
        "framed photograph",    # via 'photograph'
        "액자",                  # Korean substring
    ],
)
def test_is_flat_directional_frame_via_composite(element):
    assert is_flat_directional(element) is True


# ── G7: is_orientation_empty ──────────────────────────────────────────────
@pytest.mark.parametrize(
    "orient",
    ["", " ", "  ", "n/a", "N/A", "Not Applicable", "not applicable", "none", "unknown", "UNKNOWN"],
)
def test_is_orientation_empty_true(orient):
    assert is_orientation_empty(orient) is True


@pytest.mark.parametrize(
    "orient",
    [
        "front face facing camera",
        "back panel visible",
        "screen toward camera",
        "front printed side visible",
        "reflecting character face",
    ],
)
def test_is_orientation_empty_false(orient):
    assert is_orientation_empty(orient) is False


# ── G8: directive_for printed-page branch ────────────────────────────────
def test_directive_for_printed_page_branch():
    directive = directive_for("old photograph", "front face facing camera")
    assert "printed/content-bearing front side" in directive
    assert "old photograph" in directive
    assert "do not show only its blank back side or edge" in directive


# ── G9: directive_for visual-display branch ──────────────────────────────
def test_directive_for_visual_display_branch():
    directive = directive_for("large screen", "screen toward camera")
    assert "content-bearing front face" in directive
    assert "large screen" in directive
    # printed-page 전용 phrase 가 들어가면 안 됨
    assert "printed/content-bearing" not in directive


# ── G10: directive_for 3D directional → "" ──────────────────────────────
@pytest.mark.parametrize(
    "element",
    ["monitor", "mirror", "old door", "TV"],
)
def test_directive_for_3d_directional_returns_empty(element):
    assert directive_for(element, "screen facing camera") == ""


# ── G11: directive_for non-directional → "" ─────────────────────────────
def test_directive_for_nondirectional_returns_empty():
    assert directive_for("wooden deck", "rain-slick") == ""
    assert directive_for("ceiling", "high") == ""


# ── G12: directive_for empty orientation → "" (no fallback) ─────────────
@pytest.mark.parametrize(
    "orient",
    ["", "n/a", "not applicable", "none", "  "],
)
def test_directive_for_empty_orientation_returns_empty(orient):
    # flat directional 이라도 empty orientation 이면 "" — consumer fallback 차단
    assert directive_for("photograph", orient) == ""
```

### - [ ] Step 2: 테스트 실행 — 실패 확인

```bash
cd backend
pytest tests/pipeline/test_flat_directional_classifier.py -v
```

Expected: `ImportError` (module 미작성).

### - [ ] Step 3: 모듈 구현

Create `backend/app/modules/pipeline/flat_directional_classifier.py`:

```python
"""flat_directional_classifier — shot_staging key_bg_elements 의 평면
held / mounted directional 객체 판정 + consumer-side directive 생성.

Patch C (S26/6 photo orientation contract) 소속.

shot_staging 의 producer validator (`shot_staging.py`) 와 detail_steps.py
의 consumer (`_build_bg_element_line`) 가 동일 모듈을 import 해 카테고리
list / empty 판정 drift 0 보장.

Scope: flat directional 만. 3D fixture (monitor / mirror / door / window /
TV) 는 기존 schema description 으로 그대로 동작 — 본 모듈 무관.
"""
import re
from typing import Dict, Tuple


# 영어: word-boundary 매칭 (Patch A _noun_matches_text 패턴 carry).
# bare "frame" 제외 — "camera frame" / "frame edge" 과탐 차단.
# 복합어 "picture frame" / "framed photograph" 는 picture / photograph 가 매칭.
FLAT_NOUNS_EN: Tuple[str, ...] = (
    "photo", "photograph", "picture",
    "document", "letter", "paper", "page",
    "card", "map", "poster", "sign",
    "screen", "painting", "drawing",
)

# 한국어: substring 매칭.
FLAT_NOUNS_KO: Tuple[str, ...] = (
    "사진", "액자", "문서", "편지", "종이", "지도",
    "그림", "화면", "표지판", "포스터", "카드",
)

# Consumer 2-branch: printed / page-like.
PRINTED_PAGE_NOUNS_EN: Tuple[str, ...] = (
    "photo", "photograph", "picture",
    "document", "letter", "paper", "page",
    "card", "map", "poster",
)
PRINTED_PAGE_NOUNS_KO: Tuple[str, ...] = (
    "사진", "액자", "문서", "편지", "종이", "지도", "카드", "포스터",
)

# Consumer 2-branch: visual display-like.
VISUAL_DISPLAY_NOUNS_EN: Tuple[str, ...] = (
    "screen", "sign", "painting", "drawing",
)
VISUAL_DISPLAY_NOUNS_KO: Tuple[str, ...] = (
    "화면", "표지판", "그림",
)

EMPTY_ORIENTATION_TOKENS = frozenset({
    "", "n/a", "not applicable", "none", "unknown",
})


_WORD_BOUNDARY_CACHE: Dict[Tuple[str, ...], re.Pattern] = {}


def _matches_any(text: str, en_nouns: Tuple[str, ...], ko_nouns: Tuple[str, ...]) -> bool:
    """text 가 en_nouns (word-boundary) OR ko_nouns (substring) 중 하나에 매칭."""
    if not text:
        return False
    lowered = text.lower()
    pattern = _WORD_BOUNDARY_CACHE.get(en_nouns)
    if pattern is None:
        alt = "|".join(re.escape(n) for n in en_nouns)
        pattern = re.compile(rf"\b(?:{alt})\b")
        _WORD_BOUNDARY_CACHE[en_nouns] = pattern
    if pattern.search(lowered):
        return True
    return any(n in text for n in ko_nouns)


def is_flat_directional(element: str) -> bool:
    """element 텍스트가 평면 held / mounted directional 객체인지 판정.

    영어: word-boundary 매칭 (한 단어 단위). 한국어: substring 매칭.
    bare "frame" 제외 (camera frame / frame edge 같은 false-positive 차단 —
    복합어 picture frame / photo frame / framed photograph 는 picture /
    photo / photograph 가 매칭하므로 그대로 True).
    """
    return _matches_any(element, FLAT_NOUNS_EN, FLAT_NOUNS_KO)


def is_orientation_empty(orient: str) -> bool:
    """orientation 텍스트가 empty / 미지정 / n/a 인지 판정.

    빈 문자열, 공백, "n/a" (대소문자 무관), "not applicable", "none",
    "unknown" 은 모두 True. 그 외는 False.
    """
    if not orient:
        return True
    normalized = orient.strip().lower()
    return normalized in EMPTY_ORIENTATION_TOKENS


def directive_for(element: str, orientation: str) -> str:
    """flat directional element 에 대해 강한 영어 directive 반환.

    반환 "" 인 경우 (consumer 는 directive 를 t2i_prompt 에 inject 안 함):
      - is_flat_directional(element) False — 3D fixture / non-directional /
        empty element 모두 포함.
      - is_orientation_empty(orientation) True — legacy / pre-Patch-C cp
        호환. consumer 가 SOT 없는 fallback 을 임의 주입하지 않도록.

    Branch:
      - printed-page (photo/photograph/picture/document/letter/paper/page/
        card/map/poster + 한국어 사진/액자/문서/편지/종이/지도/카드/포스터):
        "printed/content-bearing front side" 문구.
      - visual-display (screen/sign/painting/drawing + 한국어 화면/표지판/
        그림): "content-bearing front face" 문구.
    """
    if not is_flat_directional(element):
        return ""
    if is_orientation_empty(orientation):
        return ""
    if _matches_any(element, PRINTED_PAGE_NOUNS_EN, PRINTED_PAGE_NOUNS_KO):
        return (
            f"Orientation constraint: the printed/content-bearing front "
            f"side of the {element} must be visible to the camera; do not "
            f"show only its blank back side or edge."
        )
    return (
        f"Orientation constraint: the content-bearing front face of the "
        f"{element} must be visible to the camera; do not show only its "
        f"back side or edge."
    )
```

### - [ ] Step 4: 테스트 실행 — 통과 확인

```bash
cd backend
pytest tests/pipeline/test_flat_directional_classifier.py -v
```

Expected: 모든 테스트 PASS (G1-G12 모두 통과).

### - [ ] Step 5: 커밋

```bash
git add backend/app/modules/pipeline/flat_directional_classifier.py \
        backend/tests/pipeline/test_flat_directional_classifier.py
git commit -m "$(cat <<'EOF'
feat(patch_c): add flat_directional_classifier shared helper

Patch C Task 1 — shot_staging producer (validator) 와 detail_steps.py
consumer (영어 directive inject) 양쪽이 공용으로 import 할 평면
held / mounted directional 객체 판정 helper.

FLAT_NOUNS_EN/KO + PRINTED_PAGE_NOUNS_EN/KO + VISUAL_DISPLAY_NOUNS_EN/KO
+ EMPTY_ORIENTATION_TOKENS. is_flat_directional / is_orientation_empty /
directive_for (2-branch printed vs visual). bare "frame" 제외 — false-
positive 차단 (복합어는 picture/photo 가 매칭).

테스트 G1~G12 모두 통과. pure helper — DB/HTTP/LLM 호출 없음.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 2: ShotStagingOrientationError 클래스 (core/errors.py)

**Files:**
- Modify: `backend/app/core/errors.py` (append class after `VisibleStagingDriftError`)
- Test: `backend/tests/test_errors_shot_staging_orientation.py` (NEW)

### - [ ] Step 1: 실패 테스트 작성

Create `backend/tests/test_errors_shot_staging_orientation.py`:

```python
"""ShotStagingOrientationError shape tests — Patch C Task 2.

AppError subclass 가 code / status_code / message / details 모두 spec §4.3
대로 직렬화되는지 검증.
"""
from app.core.errors import AppError, ShotStagingOrientationError


def test_shot_staging_orientation_error_is_app_error():
    exc = ShotStagingOrientationError(
        batch_num=1, total_batches=6, attempts=3,
        violations=[{"scene_index": 91, "shot_index": 1,
                     "element": "photograph", "orientation_raw": ""}],
    )
    assert isinstance(exc, AppError)


def test_shot_staging_orientation_error_code_and_status():
    exc = ShotStagingOrientationError(
        batch_num=2, total_batches=6, attempts=3, violations=[],
    )
    assert exc.code == "SHOT_STAGING_FLAT_ORIENTATION_MISSING"
    assert exc.status_code == 422


def test_shot_staging_orientation_error_message_format():
    violations = [
        {"scene_index": 91, "shot_index": 1, "element": "photograph",
         "orientation_raw": ""},
        {"scene_index": 91, "shot_index": 2, "element": "letter",
         "orientation_raw": "n/a"},
    ]
    exc = ShotStagingOrientationError(
        batch_num=1, total_batches=6, attempts=3, violations=violations,
    )
    assert "batch 1/6" in exc.message
    assert "2 flat directional element(s)" in exc.message
    assert "after 3 attempts" in exc.message


def test_shot_staging_orientation_error_details():
    violations = [
        {"scene_index": 91, "shot_index": 1, "element": "photograph",
         "orientation_raw": ""},
    ]
    exc = ShotStagingOrientationError(
        batch_num=1, total_batches=6, attempts=3, violations=violations,
    )
    assert exc.details["batch_num"] == 1
    assert exc.details["total_batches"] == 6
    assert exc.details["attempts"] == 3
    assert exc.details["violations"] == violations


def test_shot_staging_orientation_error_attributes():
    """Direct attribute access (handler serialization 와 별개 — Patch A 패턴)."""
    violations = [
        {"scene_index": 91, "shot_index": 1, "element": "photograph",
         "orientation_raw": ""},
    ]
    exc = ShotStagingOrientationError(
        batch_num=1, total_batches=6, attempts=3, violations=violations,
    )
    assert exc.batch_num == 1
    assert exc.total_batches == 6
    assert exc.attempts == 3
    assert exc.violations == violations
```

### - [ ] Step 2: 테스트 실행 — 실패 확인

```bash
cd backend
pytest tests/test_errors_shot_staging_orientation.py -v
```

Expected: `ImportError: cannot import name 'ShotStagingOrientationError' from 'app.core.errors'`.

### - [ ] Step 3: 클래스 구현

Edit `backend/app/core/errors.py` — `VisibleStagingDriftError` 클래스 다음, `app_error_handler` 정의 직전에 다음 클래스 추가:

```python
class ShotStagingOrientationError(AppError):
    """Patch C — flat directional element (photo / letter / poster / screen
    / painting 등) 가 shot_staging key_bg_elements 에 등장하면서 orientation
    이 비어 max_attempts (3회) 모두 채워지지 않았음.

    HTTP 422. shot_staging step 이 `failed` 로 마킹되어 사용자가 step 수동
    재실행 (analysis_dispatch mode=force) 으로 재진행.

    raise 는 shot_staging.py 의 batch loop 안 `call_structured` try/except
    Exception **밖**에서 실행되어야 broad except 에 swallow 되지 않는다 —
    spec §4.3 참조.
    """

    def __init__(
        self,
        *,
        batch_num: int,
        total_batches: int,
        attempts: int,
        violations: List[Dict[str, Any]],
    ):
        self.batch_num = batch_num
        self.total_batches = total_batches
        self.attempts = attempts
        self.violations = list(violations)

        details: Dict[str, Any] = {
            "batch_num": batch_num,
            "total_batches": total_batches,
            "attempts": attempts,
            "violations": list(violations),
        }
        super().__init__(
            code="SHOT_STAGING_FLAT_ORIENTATION_MISSING",
            message=(
                f"shot_staging batch {batch_num}/{total_batches}: "
                f"{len(self.violations)} flat directional element(s) "
                f"missing orientation after {attempts} attempts."
            ),
            status_code=422,
            details=details,
        )
```

### - [ ] Step 4: 테스트 실행 — 통과 확인

```bash
cd backend
pytest tests/test_errors_shot_staging_orientation.py -v
```

Expected: 5 tests PASS.

### - [ ] Step 5: 커밋

```bash
git add backend/app/core/errors.py \
        backend/tests/test_errors_shot_staging_orientation.py
git commit -m "$(cat <<'EOF'
feat(patch_c): add ShotStagingOrientationError(AppError)

Patch C Task 2 — flat directional element orientation 누락이 retry
소진 후 raise 될 에러 클래스. AppError subclass.

code="SHOT_STAGING_FLAT_ORIENTATION_MISSING", status_code=422. details
에 batch_num / total_batches / attempts / violations 노출.
VisibleStagingDriftError / StaleUpstreamError 와 동일 패턴.

5 tests PASS — code/status_code/message/details/attribute 모두 검증.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 3: _build_bg_element_line helper (detail_steps.py)

**Files:**
- Modify: `backend/app/core/steps/detail_steps.py` (add helper function above `_DetailStepMixin` class — search for `def _collect_card_inputs` (line ~606) and insert before it, or near other module-level helpers in the L20-L900 region.)
- Test: `backend/tests/core/steps/test_detail_steps_bg_element_line.py` (NEW)

### - [ ] Step 1: 실패 테스트 작성

Create `backend/tests/core/steps/test_detail_steps_bg_element_line.py`:

```python
"""_build_bg_element_line helper tests — G20~G25 (spec §8.3).

Patch C — detail_steps.py 의 L2128 inline bg_lines builder 를 helper 로
분리. flat directional + non-empty orientation 일 때만 강한 영어 directive
가 다음 줄에 추가.
"""
import pytest

from app.core.steps.detail_steps import _build_bg_element_line


# ── G20: flat printed-page render directive 포함 ─────────────────────────
def test_g20_flat_printed_page_directive_present():
    line = _build_bg_element_line(
        element="old photograph",
        state="held in hand",
        camera_use="foreground focal",
        orientation="front face facing camera",
    )
    assert "- old photograph: held in hand (foreground focal)" in line
    assert "[방향: front face facing camera]" in line
    assert "Orientation constraint: the printed/content-bearing front side" in line
    assert "old photograph" in line.split("\n")[-1]  # directive 가 다음 줄에 있음


# ── G21: flat visual-display render directive 포함 ──────────────────────
def test_g21_flat_visual_display_directive_present():
    line = _build_bg_element_line(
        element="large screen",
        state="powered on",
        camera_use="background focal",
        orientation="screen toward camera",
    )
    assert "[방향: screen toward camera]" in line
    assert "Orientation constraint: the content-bearing front face" in line
    # printed-page phrase 가 visual-display 에 섞이지 않음
    assert "printed/content-bearing" not in line


# ── G22: 3D directional render 기존 동작 unchanged ──────────────────────
def test_g22_3d_directional_no_english_directive():
    line = _build_bg_element_line(
        element="monitor",
        state="powered on",
        camera_use="background",
        orientation="screen facing camera",
    )
    assert "[방향: screen facing camera]" in line
    # 영어 directive 없음 (3D directional 은 scope guard).
    assert "Orientation constraint" not in line


# ── G23: legacy empty orientation render → no fallback ───────────────────
def test_g23_legacy_empty_orientation_no_fallback():
    line = _build_bg_element_line(
        element="old photograph",
        state="held",
        camera_use="foreground",
        orientation="",
    )
    # 빈 orientation 은 한국어 inline 도 안 붙고, 영어 directive 도 안 붙음.
    assert "[방향:" not in line
    assert "Orientation constraint" not in line
    # base line 자체는 정상.
    assert "- old photograph: held (foreground)" in line


# ── G24: legacy "n/a" / "none" / "unknown" orientation → no fallback ─────
@pytest.mark.parametrize(
    "orient",
    ["n/a", "N/A", "not applicable", "none", "unknown"],
)
def test_g24_legacy_na_orientation_no_fallback(orient):
    line = _build_bg_element_line(
        element="photograph",
        state="held",
        camera_use="foreground",
        orientation=orient,
    )
    assert "[방향:" not in line, f"orient={orient!r} should be empty"
    assert "Orientation constraint" not in line, f"orient={orient!r}"


# ── G25: non-directional + valid orient (legacy compat) ──────────────────
def test_g25_nondirectional_keeps_orientation_inline():
    """non-directional element 에 orientation 이 채워진 것은 LLM misuse 가능성.
    기존 동작 보존: inline 한국어 annotation 그대로 carry. 영어 directive 없음.
    """
    line = _build_bg_element_line(
        element="wooden deck",
        state="rain-slick",
        camera_use="texture",
        orientation="weathered surface",
    )
    assert "[방향: weathered surface]" in line  # legacy carry
    assert "Orientation constraint" not in line  # non-directional → no directive
```

### - [ ] Step 2: 테스트 실행 — 실패 확인

```bash
cd backend
pytest tests/core/steps/test_detail_steps_bg_element_line.py -v
```

Expected: `ImportError: cannot import name '_build_bg_element_line' from 'app.core.steps.detail_steps'`.

### - [ ] Step 3: helper 구현

Edit `backend/app/core/steps/detail_steps.py` — module-level helper 영역 (다른 `def _xxx` 함수들 근처, 가능하면 `def _build_entity_traits_block` (L828) 직전 또는 직후) 에 다음 함수 추가:

```python
def _build_bg_element_line(
    element: str,
    state: str,
    camera_use: str,
    orientation: str,
) -> str:
    """user_prompt 의 'bg element' 한 줄 + flat directional 일 때 영어 directive.

    Patch C — flat directional element (photo / letter / poster / screen 등)
    의 orientation 이 비어있지 않으면 강한 영어 directive 를 다음 줄에 추가.
    legacy cp / 3D directional / non-directional 은 기존 동작 보존.

    Format:
        "  - {element}: {state} ({camera_use})"  (기본)
        " [방향: {orient}]"                        (orient non-empty)
        "\n    Orientation constraint: ..."       (flat directional + non-empty)
    """
    # function-local import — Patch B-min 의 hot-reload 패턴 carry.
    # (모듈 로드 시 detail_steps 가 무거우므로 helper 호출 시 lazy import.)
    from app.modules.pipeline.flat_directional_classifier import (
        directive_for,
        is_orientation_empty,
    )

    base = f"  - {element}: {state} ({camera_use})"
    if not is_orientation_empty(orientation):
        base += f" [방향: {orientation}]"
    directive = directive_for(element, orientation)
    if directive:
        base += "\n    " + directive
    return base
```

### - [ ] Step 4: 테스트 실행 — 통과 확인

```bash
cd backend
pytest tests/core/steps/test_detail_steps_bg_element_line.py -v
```

Expected: 6 tests PASS (G20-G25).

### - [ ] Step 5: 커밋

```bash
git add backend/app/core/steps/detail_steps.py \
        backend/tests/core/steps/test_detail_steps_bg_element_line.py
git commit -m "$(cat <<'EOF'
feat(patch_c): add _build_bg_element_line helper in detail_steps.py

Patch C Task 3 — L2128 inline bg_lines builder 를 module-level helper 로
분리. flat directional element + non-empty orientation 일 때 강한 영어
directive 를 다음 줄에 추가. 3D directional / non-directional / legacy
empty 는 기존 동작 보존.

flat_directional_classifier 의 directive_for / is_orientation_empty 를
function-local import 로 lazy load (detail_steps 모듈 무거움).

G20~G25 6 tests PASS. inline 치환은 Task 4 에서.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 4: detail_steps.py inline builder 를 helper 호출로 치환

**Files:**
- Modify: `backend/app/core/steps/detail_steps.py:2128-2136`

### - [ ] Step 1: 현재 inline 코드 확인

```bash
sed -n '2120,2140p' backend/app/core/steps/detail_steps.py
```

Expected output 안에 다음 패턴 등장:
```python
if bg_elems:
    bg_lines = []
    for e in bg_elems:
        line = f"  - {e.get('element', '')}: {e.get('state', '')} ({e.get('camera_use', '')})"
        orient = e.get("orientation", "")
        if orient:
            line += f" [방향: {orient}]"
        bg_lines.append(line)
    user_prompt += f"배경 핵심 요소:\n" + "\n".join(bg_lines) + "\n"
```

### - [ ] Step 2: inline 을 helper 호출로 치환

Edit `backend/app/core/steps/detail_steps.py` — 위 블록을 다음으로 교체:

```python
if bg_elems:
    bg_lines = []
    for e in bg_elems:
        bg_lines.append(_build_bg_element_line(
            element=e.get("element", ""),
            state=e.get("state", ""),
            camera_use=e.get("camera_use", ""),
            orientation=e.get("orientation", ""),
        ))
    user_prompt += f"배경 핵심 요소:\n" + "\n".join(bg_lines) + "\n"
```

### - [ ] Step 3: 기존 scene_detail / detail_steps 회귀 테스트 실행

```bash
cd backend
pytest tests/test_scene_detail_v3.py tests/test_scene_steps_v3.py tests/core/steps/ -v 2>&1 | tail -40
```

Expected: 변경 전 회귀 0 유지. 새 test_detail_steps_bg_element_line.py 도 PASS.

특히 `_build_llm_inputs` / `_analyze_one` 관련 테스트 모두 PASS 확인. 변경 영역은 user_prompt 의 bg_elements 직렬화 한 부분이므로 hash / prompt golden 이 있다면 갱신 필요 — 다음 step 에서 식별.

### - [ ] Step 4: prompt hash / golden 영향 확인

```bash
cd backend
pytest tests/ -k "hash or golden or prompt_versions" -v 2>&1 | tail -30
```

만일 실패가 있고 그 실패가 본 변경으로 인한 user_prompt 포맷 변화 (예: 기존 `"  - photo: held [방향: x]"` 가 `"  - photo: held (...)  [방향: x]\n    Orientation..."` 로 바뀜) 라면:

- **자동 갱신 금지** — Patch A 정책 carry. 실패한 golden 의 이름을 사용자에게 보고 후 결재 받아 갱신.
- 본 patch C 는 scene_detail user_prompt 포맷을 변경하므로 prompt_hash 변경은 의도된 hash-chain trigger. test_render_prompt_card_hash 는 영향 없음 (RPC builder 미변경).

만일 실패 0 면 진행.

### - [ ] Step 5: 커밋

```bash
git add backend/app/core/steps/detail_steps.py
git commit -m "$(cat <<'EOF'
feat(patch_c): replace inline bg_lines builder with _build_bg_element_line

Patch C Task 4 — detail_steps.py L2128-L2136 의 inline bg element 줄
builder 를 Task 3 의 module-level helper 호출로 치환.

flat directional element + non-empty orientation 일 때 강한 영어
directive 가 t2i_prompt 의 user_prompt 안에 자동 inject. 3D directional
+ legacy empty 는 기존 동작 보존.

scene_detail 의 prompt_hash 변경 → downstream cp invalidation 정상 동작
(의도된 hash-chain trigger).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 5: shot_staging per-batch validator + retry 루프

**Files:**
- Modify: `backend/app/modules/pipeline/shot_staging.py` (전체 batch loop 재작성)
- Test: `backend/tests/pipeline/test_shot_staging_orientation_validator.py` (NEW)

### - [ ] Step 1: 실패 테스트 작성

Create `backend/tests/pipeline/test_shot_staging_orientation_validator.py`:

```python
"""shot_staging per-batch orientation validator tests — G13~G19 (spec §8.2).

Patch C — call_structured mock + synthetic shot data (작품 어휘 0). pipeline
module-level 테스트, DB / HTTP 통합 아님.
"""
from unittest.mock import MagicMock, patch
import pytest

from app.core.errors import ShotStagingOrientationError
from app.modules.pipeline.shot_staging import run_shot_staging


# ── synthetic 입력 데이터 (작품 어휘 0) ─────────────────────────────────
def _synthetic_inputs(shots_in_scene):
    """단일 scene_index=91 의 shot 들로 구성된 최소 입력."""
    shot_extract_data = {
        "scenes": [{
            "scene_index": 91,
            "shots": [
                {"shot_index": idx, "description": f"shot {idx}",
                 "characters": ["Subject Alpha"], "based_on_beat_title": "b"}
                for idx in shots_in_scene
            ],
        }],
    }
    shot_selection_data = {
        "scenes": [{
            "scene_index": 91,
            "selected_shot_indices": list(shots_in_scene),
        }],
    }
    scene_save_data = {
        "segments": [{"scene_index": 91, "text": "scene text"}],
    }
    entity_merge_data = {
        "characters": [{"name": "Subject Alpha"}],
    }
    vwr_data = {"t2i_context": "photorealistic cinematic still"}
    return shot_extract_data, shot_selection_data, scene_save_data, entity_merge_data, vwr_data


def _shot_with_kbe(scene_index, shot_index, element, orientation):
    """call_structured mock response 의 한 shot 항목 구성."""
    return {
        "scene_index": scene_index,
        "shot_index": shot_index,
        "perspective": "observer",
        "pov_character": "",
        "perception_mode": "direct",
        "camera_direction": "eye-level CU",
        "lighting_mood": "neutral",
        "character_angles": [],
        "key_bg_elements": [{
            "element": element,
            "state": "held",
            "orientation": orientation,
            "camera_use": "foreground",
        }],
    }


# ── G13: flat + empty orientation → ShotStagingOrientationError ─────────
def test_g13_flat_empty_raises_after_max_attempts():
    shot_extract, shot_sel, scene_save, entity, vwr = _synthetic_inputs([1])

    # 3 attempt 모두 empty orientation 반환.
    mock_response = {"shots": [_shot_with_kbe(91, 1, "photograph", "")]}

    with patch(
        "app.modules.pipeline.shot_staging.call_structured",
        return_value=mock_response,
    ) as mock_call:
        with pytest.raises(ShotStagingOrientationError) as exc_info:
            run_shot_staging(shot_extract, shot_sel, scene_save, entity, vwr)

    exc = exc_info.value
    assert exc.code == "SHOT_STAGING_FLAT_ORIENTATION_MISSING"
    assert exc.status_code == 422
    assert exc.details["attempts"] == 3
    assert exc.details["batch_num"] == 1
    assert len(exc.details["violations"]) == 1
    v = exc.details["violations"][0]
    assert v["scene_index"] == 91
    assert v["shot_index"] == 1
    assert v["element"] == "photograph"
    assert v["orientation_raw"] == ""
    # 정확히 3회 호출 (max_attempts=3).
    assert mock_call.call_count == 3


# ── G14: retry 성공 path (1차 fail → 2차 채워짐) ─────────────────────────
def test_g14_retry_succeeds_on_second_attempt():
    shot_extract, shot_sel, scene_save, entity, vwr = _synthetic_inputs([1])

    responses = [
        {"shots": [_shot_with_kbe(91, 1, "photograph", "")]},                   # attempt 1: empty
        {"shots": [_shot_with_kbe(91, 1, "photograph", "front face visible")]},  # attempt 2: valid
    ]

    with patch(
        "app.modules.pipeline.shot_staging.call_structured",
        side_effect=responses,
    ) as mock_call:
        result = run_shot_staging(shot_extract, shot_sel, scene_save, entity, vwr)

    assert result["total"] == 1
    assert result["shots"][0]["key_bg_elements"][0]["orientation"] == "front face visible"
    assert mock_call.call_count == 2

    # 2번째 호출의 user_prompt 에 retry hint 포함.
    second_call_kwargs = mock_call.call_args_list[1].kwargs
    assert "재시도" in second_call_kwargs["user_prompt"]
    assert "orientation 이 비어 있었습니다" in second_call_kwargs["user_prompt"]


# ── G15: flat + valid → 1 attempt 만 호출 ───────────────────────────────
def test_g15_flat_valid_passes_first_attempt():
    shot_extract, shot_sel, scene_save, entity, vwr = _synthetic_inputs([1])

    mock_response = {"shots": [_shot_with_kbe(91, 1, "letter", "front face facing camera")]}

    with patch(
        "app.modules.pipeline.shot_staging.call_structured",
        return_value=mock_response,
    ) as mock_call:
        result = run_shot_staging(shot_extract, shot_sel, scene_save, entity, vwr)

    assert result["total"] == 1
    assert mock_call.call_count == 1  # retry 없음


# ── G16: 3D directional + empty → pass (scope guard) ────────────────────
def test_g16_3d_directional_empty_passes():
    """monitor / mirror / TV 등 3D fixture 는 validator 가 안 잡음 (scope guard)."""
    shot_extract, shot_sel, scene_save, entity, vwr = _synthetic_inputs([1])

    mock_response = {"shots": [_shot_with_kbe(91, 1, "monitor", "")]}

    with patch(
        "app.modules.pipeline.shot_staging.call_structured",
        return_value=mock_response,
    ) as mock_call:
        result = run_shot_staging(shot_extract, shot_sel, scene_save, entity, vwr)

    assert result["total"] == 1
    assert mock_call.call_count == 1  # retry 없음, raise 없음


# ── G17: non-directional + empty → pass ─────────────────────────────────
def test_g17_nondirectional_empty_passes():
    shot_extract, shot_sel, scene_save, entity, vwr = _synthetic_inputs([1])

    mock_response = {"shots": [_shot_with_kbe(91, 1, "wooden deck", "")]}

    with patch(
        "app.modules.pipeline.shot_staging.call_structured",
        return_value=mock_response,
    ) as mock_call:
        result = run_shot_staging(shot_extract, shot_sel, scene_save, entity, vwr)

    assert result["total"] == 1
    assert mock_call.call_count == 1


# ── G18: mixed batch (1 flat-empty + 1 3D-empty + 1 flat-valid) ─────────
def test_g18_mixed_batch_only_flat_empty_in_violations():
    shot_extract, shot_sel, scene_save, entity, vwr = _synthetic_inputs([1, 2, 3])

    # batch 안 3 shot. flat-empty 1건, 3D-empty 1건, flat-valid 1건.
    mock_response = {"shots": [
        _shot_with_kbe(91, 1, "photograph", ""),                # flat-empty (violation)
        _shot_with_kbe(91, 2, "monitor", ""),                    # 3D-empty (OK)
        _shot_with_kbe(91, 3, "letter", "front face visible"),   # flat-valid (OK)
    ]}

    with patch(
        "app.modules.pipeline.shot_staging.call_structured",
        return_value=mock_response,
    ) as mock_call:
        with pytest.raises(ShotStagingOrientationError) as exc_info:
            run_shot_staging(shot_extract, shot_sel, scene_save, entity, vwr)

    exc = exc_info.value
    assert len(exc.details["violations"]) == 1  # flat-empty 1건만
    assert exc.details["violations"][0]["shot_index"] == 1
    assert exc.details["violations"][0]["element"] == "photograph"


# ── G19: call_structured 자체 실패 → 기존 failed_batches 누적 ───────────
def test_g19_call_structured_failure_increments_failed_batches():
    """call_structured 자체 raise → 기존 swallow 동작 그대로 (validator 와 직교).
    """
    shot_extract, shot_sel, scene_save, entity, vwr = _synthetic_inputs([1])

    with patch(
        "app.modules.pipeline.shot_staging.call_structured",
        side_effect=RuntimeError("transient LLM error"),
    ):
        result = run_shot_staging(shot_extract, shot_sel, scene_save, entity, vwr)

    # raise 안 됨 (broad except 가 swallow). failed_batches=1, shots=빈 list.
    assert result["failed_batches"] == 1
    assert result["total"] == 0
    assert result["shots"] == []
```

### - [ ] Step 2: 테스트 실행 — 실패 확인

```bash
cd backend
pytest tests/pipeline/test_shot_staging_orientation_validator.py -v
```

Expected: 모든 테스트 FAIL — `ImportError: cannot import name 'ShotStagingOrientationError'` (Task 2 가 이미 완료됐다면 import 통과, 본 step 에서는 G13/G14/G18 이 retry 없이 1회만 호출됨 → assertion fail).

### - [ ] Step 3: shot_staging.py 재작성

Edit `backend/app/modules/pipeline/shot_staging.py` 전체 — 다음 코드로 교체:

```python
"""shot_staging — 샷별 촬영 연출 + 배경 중요 요소 분석.

촬영감독(DP) 역할로 각 샷의 구성요소를 분석하고
창의적 카메라 연출 + 조명 + 배경 핵심 요소를 결정.

Patch C — flat directional element (photo / letter / poster / screen 등)
의 orientation 이 비면 batch retry (max_attempts=3) 후 소진 시
ShotStagingOrientationError raise. validator 는 call_structured try/except
**밖** 에서 실행 — broad except 가 swallow 하지 않도록 (§4.3).
"""
import logging
from typing import Any, Dict, List, Optional

from app.core.errors import ShotStagingOrientationError
from app.modules.llm.llm_client import call_structured
from app.modules.pipeline.flat_directional_classifier import (
    is_flat_directional,
    is_orientation_empty,
)
from app.modules.prompt_loader import load_prompt, load_schema

logger = logging.getLogger(__name__)

BATCH_SIZE = 10
MAX_ATTEMPTS = 3  # 최초 호출 1 + retry 2


def _find_flat_orientation_violations(shots: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
    """flat directional element 가 key_bg_elements 에 등장하면서 orientation
    이 빈 entry 만 골라 violation 리스트로 반환.
    """
    out: List[Dict[str, Any]] = []
    for shot in shots or []:
        si = shot.get("scene_index")
        shi = shot.get("shot_index")
        for el in shot.get("key_bg_elements", []) or []:
            element_text = (el.get("element") or "").strip()
            orient_text = el.get("orientation") or ""
            if is_flat_directional(element_text) and is_orientation_empty(orient_text):
                out.append({
                    "scene_index": si,
                    "shot_index": shi,
                    "element": element_text,
                    "orientation_raw": orient_text,
                })
    return out


def _format_retry_hint(violations: List[Dict[str, Any]]) -> str:
    """retry user_prompt 끝에 붙일 violation 리스트 + 강제 작성 지시 문구."""
    lines = [
        "",
        "",
        "[재시도 — 직전 응답에서 다음 element 의 orientation 이 비어 있었습니다.",
        " 평면 directional element (사진/문서/편지/지도/포스터/표지판/화면/그림 등) 는",
        " 카메라에 보이는 면을 반드시 작성하세요 (인쇄된 앞면 / 뒷면 / 측면 등):",
        "]",
    ]
    for v in violations:
        lines.append(
            f"  - S{v['scene_index']} Shot{v['shot_index']}: "
            f"element='{v['element']}' orientation 누락"
        )
    return "\n".join(lines)


def run_shot_staging(
    shot_extract_data: Dict,
    shot_selection_data: Dict,
    scene_save_data: Dict,
    entity_merge_data: Dict,
    vwr_data: Dict,
    camera_flow_data: Optional[Dict] = None,
    opik_metadata: Optional[Dict] = None,
) -> Dict[str, Any]:
    """shot_staging 실행 — 선택된 샷별 창의적 연출 분석.

    camera_flow_data가 주어지면 각 샷에 해당하는 scene_camera_flow 단계 정보를
    user_prompt에 주입하여, shot_staging이 독립적으로 카메라를 결정하지 않고
    씬 관통 플로우에서 파생하도록 한다.

    Patch C — batch 마다 LLM 응답의 flat directional orientation 누락을
    validator 로 검사. 위반 시 retry (max_attempts=3). 소진 시
    ShotStagingOrientationError raise (HTTP 422, step fail).
    """

    t2i_context = vwr_data.get("t2i_context", "")
    system = load_prompt("shot_staging", "system")
    schema = load_schema("shot_staging", "schema")
    system = system.format(t2i_context=t2i_context)

    # camera_flow 인덱싱: (scene_index, shot_index) → (stage, assignment)
    flow_by_shot: Dict[tuple, Dict] = {}
    flow_stages_by_scene: Dict[int, Dict[int, Dict]] = {}
    flow_summary_by_scene: Dict[int, str] = {}
    if camera_flow_data:
        for sc in (camera_flow_data.get("scenes") or []):
            si = sc.get("scene_index")
            flow_summary_by_scene[si] = sc.get("flow_summary", "")
            # None 키 방어 — LLM이 stage_index 누락 시 덮어쓰기 방지
            stages_by_idx = {
                s["stage_index"]: s
                for s in sc.get("flow_stages", [])
                if s.get("stage_index") is not None
            }
            flow_stages_by_scene[si] = stages_by_idx
            for a in sc.get("shot_assignments", []):
                shi = a.get("shot_index")
                stage_idx = a.get("stage_index")
                if shi is None or stage_idx is None:
                    continue
                stage = stages_by_idx.get(stage_idx)
                if stage:
                    flow_by_shot[(si, shi)] = {"stage": stage, "assignment": a}

    # 선택된 shot 인덱스 맵: scene_index → [shot_index, ...]
    selected_map = {}
    for s in shot_selection_data.get("scenes", []):
        si = s.get("scene_index")
        selected = s.get("selected_shot_indices", [])
        if selected:
            selected_map[si] = set(selected)

    # 씬 원본 텍스트 맵
    scene_text_map = {}
    for seg in scene_save_data.get("segments", []):
        si = seg.get("scene_index")
        scene_text_map[si] = seg.get("text", "")

    # 인물 이름 목록
    char_names = [c.get("name", "") for c in entity_merge_data.get("characters", [])]

    # 선택된 샷 수집
    items = []
    for s in shot_extract_data.get("scenes", []):
        si = s.get("scene_index")
        selected = selected_map.get(si, set())
        for sh in s.get("shots", []):
            shi = sh.get("shot_index")
            if shi in selected:
                items.append({
                    "scene_index": si,
                    "shot_index": shi,
                    "description": sh.get("description", ""),
                    "characters": sh.get("characters", []),
                    "beat_title": sh.get("based_on_beat_title", ""),
                    "scene_text": scene_text_map.get(si, ""),
                })

    if not items:
        logger.info("shot_staging: no selected shots found")
        return {"shots": [], "total": 0}

    logger.info("shot_staging: %d selected shots to analyze", len(items))

    # 배치 처리
    all_results = []
    failed_batches = 0
    total_batches = (len(items) - 1) // BATCH_SIZE + 1

    for batch_start in range(0, len(items), BATCH_SIZE):
        batch = items[batch_start:batch_start + BATCH_SIZE]
        batch_num = batch_start // BATCH_SIZE + 1

        user_lines = []
        for bi in batch:
            chars_str = ", ".join(bi["characters"]) if bi["characters"] else "(인물 없음)"
            flow_lines: List[str] = []
            flow_entry = flow_by_shot.get((bi["scene_index"], bi["shot_index"]))
            if flow_entry:
                stage = flow_entry["stage"]
                assignment = flow_entry["assignment"]
                summary = flow_summary_by_scene.get(bi["scene_index"], "")
                if summary:
                    flow_lines.append(f"  씬 플로우 요약: {summary}")
                flow_lines.append(
                    f"  플로우 단계 {stage.get('stage_index')}[{stage.get('stage_label', '')}] "
                    f"position={assignment.get('flow_position', '')}"
                )
                if stage.get("camera_position"):
                    flow_lines.append(f"    camera_position: {stage['camera_position']}")
                if stage.get("camera_motion"):
                    flow_lines.append(f"    camera_motion: {stage['camera_motion']}")
                if stage.get("visual_focus"):
                    flow_lines.append(f"    visual_focus: {stage['visual_focus']}")
                if stage.get("transition_to_next"):
                    flow_lines.append(f"    transition_to_next: {stage['transition_to_next']}")
                dev = assignment.get("deviation_note", "")
                if dev:
                    flow_lines.append(f"    이 샷의 미세 조정: {dev}")
            flow_block = ("\n" + "\n".join(flow_lines)) if flow_lines else ""

            user_lines.append(
                f"[씬{bi['scene_index']} Shot{bi['shot_index']}]\n"
                f"  Beat: {bi['beat_title']}\n"
                f"  인물: {chars_str}\n"
                f"  묘사: {bi['description']}"
                f"{flow_block}\n"
                f"  씬 원문: {bi['scene_text']}"
            )

        base_user_prompt = (
            f"등록된 인물 목록: {', '.join(char_names)}\n\n"
            f"아래 샷들의 촬영 연출을 설계하세요:\n\n"
            + "\n\n".join(user_lines)
        )

        # Patch C — per-attempt validator + retry. validator raise 는 try 외부.
        violations: List[Dict[str, Any]] = []
        batch_failed_at_call = False

        for attempt in range(1, MAX_ATTEMPTS + 1):
            user_prompt = base_user_prompt if attempt == 1 else (
                base_user_prompt + _format_retry_hint(violations)
            )

            # call_structured 만 try 안. broad except 는 transient/네트워크 실패만 처리.
            try:
                result = call_structured(
                    step="shot_staging",
                    system_prompt=system,
                    user_prompt=user_prompt,
                    response_schema=schema,
                    opik_metadata=opik_metadata,
                )
            except Exception as e:
                failed_batches += 1
                logger.warning(
                    "shot_staging batch %d/%d attempt %d failed: %s",
                    batch_num, total_batches, attempt, e,
                )
                batch_failed_at_call = True
                break  # exit attempt loop — next batch

            # ↓ try 외부 — validator raise 가 batch loop 위로 propagate.
            batch_shots = result.get("shots", [])
            violations = _find_flat_orientation_violations(batch_shots)

            if not violations:
                all_results.extend(batch_shots)
                logger.info(
                    "shot_staging batch %d/%d attempt %d: %d shots ok",
                    batch_num, total_batches, attempt, len(batch_shots),
                )
                break

            if attempt < MAX_ATTEMPTS:
                logger.info(
                    "shot_staging batch %d/%d attempt %d: %d violations, retry",
                    batch_num, total_batches, attempt, len(violations),
                )
                continue

            # attempts 소진 — ShotStagingOrientationError 는 try 밖이라 swallow 안 됨.
            raise ShotStagingOrientationError(
                batch_num=batch_num,
                total_batches=total_batches,
                attempts=MAX_ATTEMPTS,
                violations=violations,
            )

        if batch_failed_at_call:
            continue  # 다음 batch 로 (기존 동작 유지)

    if failed_batches:
        logger.warning("shot_staging: %d/%d batches failed", failed_batches, total_batches)

    return {
        "shots": all_results,
        "total": len(all_results),
        "failed_batches": failed_batches,
    }
```

### - [ ] Step 4: 테스트 실행 — G13~G19 통과 확인

```bash
cd backend
pytest tests/pipeline/test_shot_staging_orientation_validator.py -v
```

Expected: 7 tests PASS.

### - [ ] Step 5: 기존 shot_staging 회귀 테스트 실행

```bash
cd backend
pytest tests/ -k "shot_staging or shot_extract" -v 2>&1 | tail -30
```

Expected: 회귀 0 — 기존 shot_staging 테스트 모두 통과. 단 다른 모듈이 `from app.modules.pipeline.shot_staging import ...` 하는 곳에서 추가 import (`ShotStagingOrientationError`) 가 호환되는지 검증.

### - [ ] Step 6: 커밋

```bash
git add backend/app/modules/pipeline/shot_staging.py \
        backend/tests/pipeline/test_shot_staging_orientation_validator.py
git commit -m "$(cat <<'EOF'
feat(patch_c): shot_staging per-batch flat orientation validator + retry

Patch C Task 5 — flat directional element (photo / letter / poster /
screen 등) 가 shot_staging 의 key_bg_elements 에 등장하면서 orientation
이 비면 batch retry. max_attempts=3 (최초 1 + retry 2) 소진 시
ShotStagingOrientationError raise (HTTP 422).

핵심 — validator raise 는 call_structured try/except Exception **밖**에서
실행되어 broad except 에 swallow 되지 않음 (spec §4.3). call_structured
자체 transient 실패는 기존 failed_batches 누적 동작 그대로.

retry user_prompt 에는 violations 리스트 + "재시도" 한국어 안내 + 평면
directional 카테고리 예시가 append 되어 LLM 이 정확히 어떤 element 가
누락됐는지 인식.

G13~G19 7 tests PASS.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 6: shot_staging prompt pack v9 생성

**Files:**
- Create: `prompts/_base/shot_staging/9.202605121200/schema.json`
- Create: `prompts/_base/shot_staging/9.202605121200/system.md`

### - [ ] Step 1: v8 → v9 디렉토리 복사

```bash
mkdir -p prompts/_base/shot_staging/9.202605121200
cp prompts/_base/shot_staging/8.202604201230/schema.json prompts/_base/shot_staging/9.202605121200/schema.json
cp prompts/_base/shot_staging/8.202604201230/system.md prompts/_base/shot_staging/9.202605121200/system.md
ls prompts/_base/shot_staging/9.202605121200/
```

Expected output:
```
schema.json
system.md
```

### - [ ] Step 2: schema.json description 확장

Edit `prompts/_base/shot_staging/9.202605121200/schema.json` — `key_bg_elements` 안 `orientation` 객체의 `description` 필드를 다음으로 교체:

기존:
```json
"description": "For directional objects (monitor, mirror, door, TV, window): which side faces camera. e.g. 'screen facing camera', 'back panel visible', 'reflecting character face'. Empty string if not applicable."
```

새 description (한 줄로 직렬화):
```json
"description": "For directional objects, specify which side faces the camera. (a) 3D fixtures: monitor, mirror, door, TV, window — e.g. 'screen facing camera', 'back panel visible', 'reflecting character face'. (b) Flat held / mounted objects (photo, photograph, picture, letter, document, paper, page, card, map, poster, sign, screen, painting, drawing — Korean equivalents: 사진/액자/문서/편지/종이/지도/그림/화면/표지판/포스터/카드) — for this category orientation MUST be filled (empty string is NOT acceptable); e.g. 'front printed side facing camera', 'back side visible', 'edge-only', 'angled toward character but front face still visible to camera'. Empty string only allowed for non-directional elements (deck, wall, floor, ceiling, ambient texture)."
```

### - [ ] Step 3: system.md `## 배경 중요 요소` 섹션 확장

Edit `prompts/_base/shot_staging/9.202605121200/system.md` — `## 배경 중요 요소` 섹션 (L160 부근 — "각 샷에서 **카메라에 보이거나 촬영에 활용될 배경 요소**를 추려내세요." 문장으로 시작) 의 **방향이 있는 물체** 블록을 다음으로 교체:

기존 (4 줄):
```
- **방향이 있는 물체**(모니터, 거울, 문, 창문, TV 등)는 카메라에 대한 방향도 명시:
  - 예: "모니터: 켜짐, 화면이 카메라를 향함" / "모니터: 켜짐, 뒷면만 보임"
  - 예: "문: 열림, 카메라 쪽으로 열림" / "거울: 인물의 얼굴이 반사되어 보임"
  - 이 정보가 없으면 이미지 생성 시 물체의 보이는 면이 잘못될 수 있음
```

새:
```
- **방향이 있는 물체**는 카메라에 대한 방향도 반드시 명시:
  - **3D fixture** (모니터/거울/문/창문/TV 등): "모니터: 켜짐, 화면이 카메라를 향함" / "거울: 인물의 얼굴이 반사되어 보임" / "문: 열림, 카메라 쪽으로 열림"
  - **평면 held / mounted 객체** (사진/액자/문서/편지/종이/지도/포스터/표지판/화면/그림/카드 — 영어 photo/photograph/picture/letter/document/paper/page/card/map/poster/sign/screen/painting/drawing): 인쇄면/콘텐츠면이 카메라에 보이는지 반드시 명시. 예: "사진: 인쇄된 앞면이 카메라를 향함" / "편지: 글이 적힌 면이 카메라에 보임" / "지도: 뒷면 (백지) 만 보임" / "표지판: 글자 면이 카메라 반대쪽"
  - 이 정보가 없으면 이미지 생성 시 물체의 보이는 면이 잘못될 수 있음. 평면 객체의 orientation 빈 값 금지 — 반드시 어느 면이 보이는지 작성.
```

### - [ ] Step 4: prompt_loader 가 v9 를 latest 로 선택하는지 검증

```bash
cd backend
python3 -c "
from app.modules.prompt_loader import load_prompt, load_schema
sys = load_prompt('shot_staging', 'system')
sch = load_schema('shot_staging', 'schema')
print('system.md head:', sys[:80])
print()
print('schema orientation desc head:', sch['properties']['shots']['items']['properties']['key_bg_elements']['items']['properties']['orientation']['description'][:120])
"
```

Expected:
- `system.md head` 가 "당신은 영화 촬영감독(DP)입니다..." 로 시작 (system.md 는 v8 = v9 동일 prefix).
- `schema orientation desc head` 가 `"For directional objects, specify which side faces the camera. (a) 3D fixtures..."` 로 시작 (v9 description).

만일 schema description 이 v8 의 `"For directional objects (monitor, mirror, door, TV, window):..."` 로 나오면 v9 디렉토리 인식 실패 → 디렉토리명 확인 (`9.202605121200`, secondary 가 정확히 12자리 timestamp).

### - [ ] Step 5: 신규 v9 pack 동작 확인용 smoke test

`backend/tests/prompts/` 디렉토리 안 prompt versions 테스트가 있다면 그쪽 활용. 그렇지 않으면 다음 inline 명령으로 v9 선택 확인:

```bash
cd backend
python3 -c "
from app.modules.prompt_loader import _resolve_stem_in_pack
fpath, found_ver, versions = _resolve_stem_in_pack('shot_staging', 'system', ext='.md')
print(f'system.md found_ver={found_ver}, latest={versions[0]}')
assert found_ver == '9.202605121200', f'expected v9, got {found_ver}'
fpath, found_ver, versions = _resolve_stem_in_pack('shot_staging', 'schema', ext='.json')
print(f'schema.json found_ver={found_ver}, latest={versions[0]}')
assert found_ver == '9.202605121200', f'expected v9, got {found_ver}'
print('OK — v9 pack resolved correctly')
"
```

Expected: `OK — v9 pack resolved correctly`.

### - [ ] Step 6: 커밋

```bash
git add prompts/_base/shot_staging/9.202605121200/
git commit -m "$(cat <<'EOF'
feat(prompts): shot_staging v9.202605121200 — flat directional category

Patch C Task 6 — shot_staging prompt pack v9. schema.json 의
key_bg_elements[].orientation description 에 평면 held / mounted 객체
카테고리 (photo / letter / document / poster / sign / screen / painting
/ drawing — 한국어 사진/액자/문서/편지/종이/지도/그림/화면/표지판/포스터/
카드) 를 directional 후보로 enumerate. 평면 객체는 orientation 빈 값
금지 — empty string is NOT acceptable 명시.

system.md 의 ## 배경 중요 요소 섹션도 같은 카테고리 enumerate + 작성
예시 ("사진: 인쇄된 앞면이 카메라를 향함", "지도: 뒷면 (백지) 만 보임"
등) 추가. 3D fixture 와 평면 객체를 분리 표기.

prompt_loader 가 numeric-desc 순회로 9.202605121200 을 자동 선택 (코드
핀 없음 — v8 archive 그대로 유지).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 7: Production residue grep gate + broader regression

**Files:**
- Read-only verification — no code changes.

### - [ ] Step 1: scenario residue grep gate (changed-files 기준)

```bash
SCENARIO_FORBIDDEN='수리영|혜수|민숙|인우|금월도|시골 식당|둥근 원|두 소녀|옥탑방|조타실|갑판|낡은 흑백|흑백 사진|낡은 사진|낡은 액자|S11\b|S16\b|S26\b|S27\b|S28\b|\bP02\b|\bP03\b|\bP17\b|C05\b'

git diff main...HEAD --name-only \
  | grep -E '^(backend/app|prompts|backend/scripts)/' \
  | xargs -I{} grep -EnH "$SCENARIO_FORBIDDEN" {} 2>/dev/null
```

Expected: **출력 없음** (zero-hit). production code / prompt 안 시나리오 어휘 0.

만일 출력이 있으면 해당 파일 / 라인 확인 후 category-only / synthetic 으로 교체. 테스트 파일 (`backend/tests/...`) 은 본 gate 의 대상이 아님 (synthetic 91~93 / Subject Alpha 만 사용).

### - [ ] Step 2: test fixture synthetic IDs grep (P91~/C91~/L91~ 등) — production code 잔재 검증

```bash
git diff main...HEAD --name-only \
  | grep -E '^(backend/app|prompts|backend/scripts)/' \
  | xargs -I{} grep -EnH '\bP9[0-9]\b|\bC9[0-9]\b|\bL9[0-9]\b|\bO9[0-9]\b|Subject\s+(Alpha|Beta|Gamma)' {} 2>/dev/null
```

Expected: **출력 없음**. synthetic IDs (P91 / C91 / L91 / Subject Alpha) 도 production code / prompt 에 0.

### - [ ] Step 3: broader pytest 회귀

```bash
cd backend
pytest tests/ 2>&1 | tail -30
```

Expected:
- 회귀 0 (pre-existing 1건 carry — `test_analysis_dispatch_service.py::test_select_steps_for_category_planning_doc_inclusion`, 사용자 우선순위 #4 별도 영역, Patch C 와 직교).
- 신규 추가 테스트 (G1~G25 + ShotStagingOrientationError shape 5건) 모두 PASS.

만일 본 patch 와 무관한 신규 실패가 발생하면 stash + retest 로 pre-existing 여부 확인 후 사용자에게 보고.

### - [ ] Step 4: 변경 요약 보고

다음 정보 사용자에게 전달:

```
Patch C implementation 완료:
- 신규 모듈: flat_directional_classifier (Task 1)
- 신규 에러: ShotStagingOrientationError (Task 2)
- 신규 helper: _build_bg_element_line (Task 3)
- inline 치환: detail_steps.py L2128 (Task 4)
- validator + retry: shot_staging.py (Task 5)
- prompt pack: v9.202605121200 (Task 6)

테스트 결과:
- 신규: G1~G25 + Error shape 5건 = 30 PASS
- 회귀: 0 (pre-existing 1건 외)

다음 단계: code review (Codex 또는 inline) → manual canary (Task 8).
```

### - [ ] Step 5: 본 task 자체 커밋 불필요

Task 7 은 검증 only. 변경 사항이 있다면 직전 task 에서 잡아냈어야 함. 사용자 가 코드 review 후 manual canary 진행.

---

## Task 8: Manual canary (코드 review 통과 후 — 운영자 실행)

> 이 task 는 코드 변경 없음. Patch A 패턴 carry — implementation merge 후 운영 환경에서 수동 검증. spec §9 와 동일한 절차.

**Pre-requisite:**
- Task 1~7 모두 commit 후 main 에 merge.
- backend uvicorn `--reload` 가 PID `20817` port 8000 에서 동작 (Patch A carry).
- canary 대상: PID `02829fe8` / EP `fc38cf03` / S26 Shot 6.

### - [ ] Step 1: episode 의 S26/6 still_id 조회

```bash
PROJECT_ID="02829fe8-af47-4dda-9cfe-af9457a4cd5b"
EPISODE_ID="fc38cf03-3863-4cdb-936a-3ef99438242c"

# DB or scene_still REST endpoint 로 still_id 조회. Patch A 의 canary
# 예시 따라 — 본 patch 의 dispatch 호출에는 still_id 가 필요한 경우 있음.
psql -d theroad -c "SELECT id, scene_index, shot_index FROM scene_still
  WHERE episode_id='${EPISODE_ID}' AND scene_index=26 AND shot_index=6;"
```

Expected: 1 row, `id` 컬럼이 S26/6 의 still_id.

### - [ ] Step 2: shot_staging force re-run (전체 episode)

```bash
curl -X POST \
  --cookie /tmp/theroad_cookie.txt \
  "http://localhost:8000/api/analysis-dispatch?project_id=${PROJECT_ID}&episode_id=${EPISODE_ID}&mode=force&step=shot_staging"
```

Expected (HTTP 200):
- 약 6 batches × LLM 호출 (수 분 소요).
- log `/private/tmp/theroad_server.log` 안 `shot_staging batch N/6 attempt 1: ... shots ok` 줄 모두 등장.
- `ShotStagingOrientationError` 가 raise 되지 않음 (prompt v9 가 LLM 을 잘 유도 → orientation 자동 채움).

### - [ ] Step 3: cp diff 검증 — S26/6 의 orientation 채워짐

```bash
python3 -c "
import json
mf = './projects/${PROJECT_ID}/checkpoints/episodes/${EPISODE_ID}/shot_staging/manifest.json'.replace('\${PROJECT_ID}', '02829fe8-af47-4dda-9cfe-af9457a4cd5b').replace('\${EPISODE_ID}', 'fc38cf03-3863-4cdb-936a-3ef99438242c')
d = json.load(open(mf))
shots = d.get('data', {}).get('shots') or d.get('shots', [])
m = [s for s in shots if s.get('scene_index') == 26 and s.get('shot_index') == 6]
assert m, 'S26/6 not found'
for el in m[0].get('key_bg_elements', []):
    print(f'element={el[\"element\"]!r}  orientation={el[\"orientation\"]!r}')
"
```

Expected output 안:
- `element="old black-and-white photograph"` (또는 LLM 변형) → `orientation` 이 빈 문자열 / "n/a" 아닌 specific 문구 (예: `"front printed side facing camera"`).
- 다른 element (`fishing boat deck`, `wheelhouse doorway`) 는 기존과 동일.

만일 orientation 이 여전히 비어있으면:
1. retry 가 LLM 을 설득 못한 케이스 — `ShotStagingOrientationError` 가 raise 됐어야 함. log 확인.
2. v9 schema description / system.md 의 평면 카테고리 enumerate 가 LLM 에 미도달 가능성 — prompt 인쇄해서 확인.

### - [ ] Step 4: scene_detail force re-run (downstream)

```bash
curl -X POST \
  --cookie /tmp/theroad_cookie.txt \
  "http://localhost:8000/api/analysis-dispatch?project_id=${PROJECT_ID}&episode_id=${EPISODE_ID}&mode=force&step=scene_detail"
```

또는 (단건만 빠르게):

```bash
curl -X POST \
  --cookie /tmp/theroad_cookie.txt \
  -H "Content-Type: application/json" \
  -d '{"scene_index": 26, "shot_index": 6, "mode": "force"}' \
  "http://localhost:8000/api/scene-detail-redo?project_id=${PROJECT_ID}&episode_id=${EPISODE_ID}"
```

(스펙: `backend/app/services/scene_detail_redo_service.py` 참조.)

### - [ ] Step 5: cp t2i_prompt 검증 — directive 등장

```bash
python3 -c "
import json
mf = './projects/02829fe8-af47-4dda-9cfe-af9457a4cd5b/checkpoints/episodes/fc38cf03-3863-4cdb-936a-3ef99438242c/scene_detail/manifest.json'
d = json.load(open(mf))
scenes = d.get('data', {}).get('scenes') or d.get('scenes', [])
m = [s for s in scenes if s.get('scene_index') == 26 and s.get('shot_index') == 6]
assert m, 'S26/6 not found'
for var in m[0].get('t2i_variations', []):
    print('---')
    print(var['t2i_prompt'])
"
```

Expected:
- t2i_prompt 안에 `"Orientation constraint: the printed/content-bearing front side"` 또는 비슷한 영어 directive 등장 (LLM 이 user_prompt 의 directive 를 prompt 본문으로 통합한 결과).
- 단 LLM 이 directive 의 wording 을 그대로 carry 한다는 보장은 없음 — 의미 보존만 확인 (예: "the front side of the photograph is visible to the camera", "the printed face is shown").

### - [ ] Step 6: S26/6 still image regenerate

```bash
# Patch A canary 와 동일 — /stills/{still_id}/generate-image endpoint.
STILL_ID="<S26/6 still_id from Step 1>"

curl -X POST \
  --cookie /tmp/theroad_cookie.txt \
  "http://localhost:8000/api/stills/${STILL_ID}/generate-image?project_id=${PROJECT_ID}&episode_id=${EPISODE_ID}"
```

Expected (HTTP 200): PNG 생성 + DB 의 scene_still.image_path 갱신.

### - [ ] Step 7: 시각 검증

브라우저에서 생성된 PNG 열기:

```
http://localhost:8000/projects/${PROJECT_ID}/images/${EPISODE_ID}/scene/<S26_Shot6_image_hash>.png
```

또는 frontend UI 의 episode preview 에서 S26/6 확인.

**PASS 조건:**
- 사진의 인쇄면 (앞면, 인물 / 배경 이미지가 있는 면) 이 카메라에 보임.
- 등 뒷면 (블랭크 / 백지) 만 보이지 않음.
- 인우의 시선이 사진 쪽으로 향함 (기존 동작 보존).

**FAIL 시:**
- v9 prompt directive 효과 미달 → spec v1.2 escalate.
- LLM 이 retry 에도 orientation 누락 → `ShotStagingOrientationError` 분석 + prompt 강화.

### - [ ] Step 8: closure 보고

다음 정보 사용자에게 전달:

```
Patch C canary 결과:
- shot_staging force re-run: PASS / FAIL — orientation 채워짐 여부
- scene_detail force re-run: PASS / FAIL — t2i_prompt 안 directive 등장 여부
- still image regen: PASS / FAIL — 사진 앞면 카메라 노출 여부

(다른 5개 시각 결함 shot 의 regression 검증은 별도 — Patch C 가 시각
결과 변경할 가능성은 flat directional element 가 있는 shot 한정.)
```

---

## Self-Review

### Spec coverage

| Spec § | 구현 task |
|---|---|
| §1.1 (in scope) | Task 1~6 모두 |
| §1.2 (out of scope: 3D unchanged) | Task 1 G3 + Task 3 G22 검증 |
| §1.3 (defect anchor) | Task 8 canary (S26/6) |
| §2 (3-tier defense architecture) | Task 1 (helper) + Task 5 (producer) + Task 4 (consumer) |
| §3 (classifier 명세) | Task 1 G1~G12 |
| §3.4 (directive_for 계약) | Task 1 G10~G12 |
| §3.5 (매칭 helper / regex cache) | Task 1 module 코드 |
| §4.1 (prompt v9 bump) | Task 6 |
| §4.2 (코드 핀 검증) | Task 6 Step 4~5 |
| §4.3 (per-batch validator + retry, try 외부) | Task 5 + Task 2 (error class) |
| §5.1 (helper 추출) | Task 3 |
| §5.2 (호출부 교체) | Task 4 |
| §6 (files to modify) | Plan 의 File Structure 와 1:1 |
| §7 (error class location) | Task 2 errors.py |
| §8.1 (Unit G1~G12) | Task 1 Step 1 |
| §8.2 (Module G13~G19) | Task 5 Step 1 |
| §8.3 (Module G20~G25) | Task 3 Step 1 |
| §8.4 (residue grep gate) | Task 7 Step 1~2 |
| §8.5 (hash regression) | Task 4 Step 4 |
| §8.6 (회귀) | Task 7 Step 3 |
| §9 (manual canary) | Task 8 |
| §10 (open items / TBD) | prompt_loader VERIFIED + error class DECIDED — Task 2/6 에서 닫힘 |
| §11 (P3 out of scope) | 명시적 미포함 |
| §12 (Patch A 함정 carry) | 각 task 의 commit 메시지 + Task 7 검증 |

### Placeholder scan

- `TBD` / `TODO` — 본 plan 안 0 (spec 의 §10 TBD 는 v1.1 에서 모두 닫힘).
- `implement later` / `fill in details` / `appropriate` / "Add error handling" — 0.
- "Similar to Task N" 패턴 — 0 (모든 task 안에 code/test/commit step 풀 코드 inline).

**예외**: Task 3 Step 1 에 잘못된 `@pytest_param_orientations = ...` 줄이 syntax error 로 들어가 있었음 — 본문 안에서 즉시 정정 안내 + G24 inline 리스트로 대체. implementation 시 그 잘못된 줄은 paste 하지 말 것.

### Type consistency

- `is_flat_directional(element: str) -> bool` — Task 1 / Task 3 / Task 5 모두 동일 시그니처.
- `is_orientation_empty(orient: str) -> bool` — 동일.
- `directive_for(element: str, orientation: str) -> str` — 동일.
- `_build_bg_element_line(element, state, camera_use, orientation) -> str` — Task 3 정의 + Task 4 caller 동일 keyword args.
- `ShotStagingOrientationError(*, batch_num, total_batches, attempts, violations)` — Task 2 정의 + Task 5 raise + Task 5 G13 assertion 모두 동일 kwargs.

### Spec gap

없음. spec 의 모든 in-scope 요구사항이 task 로 mapping 됨.

---

## Execution Handoff

**Plan complete and saved to `docs/superpowers/plans/2026-05-12-patch-c-photo-orientation-implementation.md`. Two execution options:**

**1. Subagent-Driven (recommended)** — Fresh subagent per task + two-stage review (impl + test 각각 검증 + Codex review 옵션). Patch A/B-min 패턴 carry. 각 task 의 commit gate 가 명확해 review 회로가 단순.

**2. Inline Execution** — Execute tasks in this session using executing-plans, batch execution with checkpoints. Patch A 가 inline 으로 진행했고 효율적이었음. 단 본 session 의 컨텍스트가 이미 178k tokens 이라 후속 6 task × test/impl/review 가 누적되면 컨텍스트 압박 ↑.

**Which approach?**
