# Patch C / Area A — shot_staging directionality_class SOT Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans. Steps use checkbox (`- [ ]`) syntax.

**Umbrella spec**: `docs/superpowers/specs/2026-05-12-llm-structured-sot-migration-design.md` — Area A.
**Goal**: S26/6 photo orientation 결함 직접 closure + `directionality_class` enum (5-value) + `orientation` NL 필드의 LLM SOT 신설. shot_staging schema 에 LLM 이 의미 분류 + code 는 enum 일관성만 검증 + consumer 는 enum branching 만.
**Architecture**: 3-tier defense — Producer (shot_staging prompt v9 + per-batch validator + retry) → Consumer (detail_steps.py 5-class directive branching). 보조 classifier 모듈 0. Code 가 element 문자열 분류 0.
**Tech Stack**: Python 3.12 / FastAPI / pytest / Gemini structured output.

**LLM 모델 가설**: `gemini-pro` (shot_staging 이 이미 사용 중). micro-benchmark 후 확정 가능.

---

## 0. Self-review baseline (이전 v1.0 plan 의 함정 carry)

이전 plan v1.0 (폐기) 의 함정:
1. `flat_directional_classifier.py` 모듈 신설 — noun list 박는 brittle heuristic. 본 plan 은 **classifier 모듈 신설 0**.
2. Korean/English bilingual noun list — 동의어 무한 생성 silently skip. 본 plan 은 element 문자열 자체를 코드가 절대 분류 안 함.
3. Test 73 PASS 가 architecture 결함 가림. 본 plan 의 G25 가 "element 문자열 무관 직접 록인" (snapshot / polaroid / 부적 같은 임의 단어로도 LLM emit enum 만으로 directive 동작).
4. v1.1 review 의 try/except 경계 (validator try 외부 raise) 그대로 carry.
5. legacy cp 호환 (consumer 는 enum 누락 시 영어 directive 0, fallback 금지).

---

## 1. File Structure

**Create (3 + 3 test)**:
- `prompts/_base/shot_staging/9.YYYYMMDDHHmm/schema.json` — `directionality_class` enum 필드 추가.
- `prompts/_base/shot_staging/9.YYYYMMDDHHmm/system.md` — 5 class 개념 설명 (noun 예시 0).
- `backend/tests/test_errors_shot_staging_orientation.py` — `ShotStagingOrientationError` 5 shape test.
- `backend/tests/pipeline/test_shot_staging_orientation_validator.py` — G1~G10 validator test.
- `backend/tests/core/steps/test_detail_steps_bg_element_line.py` — G16~G26 consumer helper test.
- `backend/tests/_gate/test_semantic_regex_ban.py` — umbrella spec §3.1 Gate 1 의 첫 도입 (changed-lines only).

**Modify (3)**:
- `backend/app/core/errors.py` — `ShotStagingOrientationError(AppError)` 신규.
- `backend/app/modules/pipeline/shot_staging.py` — per-batch validator + retry + try/except 외부 raise.
- `backend/app/core/steps/detail_steps.py` — `_DIRECTIVE_TEMPLATES` literal 상수 + `_build_bg_element_line` helper + L2128-L2136 inline 치환.

**Unchanged (verify)**:
- `backend/app/core/steps/render_prompt_card.py` — Area B 영역. 본 plan 미터치.
- `prompts/_base/shot_staging/8.202604201230/` — archive 그대로.

---

## Task 1: 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 Area A Task 1.

AppError subclass 가 code / status_code / message / details 모두 spec
대로 직렬화되는지 검증.
"""
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": "any-noun", "directionality_class": "content_surface",
                     "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_ORIENTATION_MISSING"
    assert exc.status_code == 422


def test_shot_staging_orientation_error_message_format():
    violations = [
        {"scene_index": 91, "shot_index": 1, "element": "any",
         "directionality_class": "content_surface", "orientation_raw": ""},
        {"scene_index": 91, "shot_index": 2, "element": "any",
         "directionality_class": "reflective_surface", "orientation_raw": ""},
    ]
    exc = ShotStagingOrientationError(
        batch_num=1, total_batches=6, attempts=3, violations=violations,
    )
    assert "batch 1/6" in exc.message
    assert "2 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": "any",
         "directionality_class": "content_surface", "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():
    violations = [
        {"scene_index": 91, "shot_index": 1, "element": "any",
         "directionality_class": "content_surface", "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'`.

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

Edit `backend/app/core/errors.py` — `VisibleStagingDriftError` 다음, `app_error_handler` 직전:

```python
class ShotStagingOrientationError(AppError):
    """Patch C / Area A — content_surface / reflective_surface element 의
    orientation 이 max_attempts (3회) 모두 빈 값으로 남음.

    HTTP 422. shot_staging step fail. 운영자가 analysis_dispatch
    mode=force 로 step 수동 재실행.

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

    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_ORIENTATION_MISSING",
            message=(
                f"shot_staging batch {batch_num}/{total_batches}: "
                f"{len(self.violations)} element(s) with content_surface/"
                f"reflective_surface class missing orientation after "
                f"{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(area_a): add ShotStagingOrientationError(AppError)

Patch C / Area A Task 1 — content_surface / reflective_surface element 의
orientation 누락이 retry 소진 후 raise 될 에러 클래스. AppError subclass.

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

5 tests PASS.

Umbrella spec: docs/superpowers/specs/2026-05-12-llm-structured-sot-migration-design.md
Area: A (shot_staging directionality_class SOT)

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

---

## Task 2: shot_staging prompt pack v9

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

(timestamp 은 실행 시점에 `date +%Y%m%d%H%M` 으로 갱신.)

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

```bash
TS=$(date +%Y%m%d%H%M)
mkdir -p prompts/_base/shot_staging/9.${TS}
cp prompts/_base/shot_staging/8.202604201230/schema.json prompts/_base/shot_staging/9.${TS}/schema.json
cp prompts/_base/shot_staging/8.202604201230/system.md prompts/_base/shot_staging/9.${TS}/system.md
ls prompts/_base/shot_staging/9.${TS}/
```

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

### - [ ] Step 2: schema.json — `directionality_class` 필드 추가 + `orientation` description 갱신

Edit `prompts/_base/shot_staging/9.${TS}/schema.json` — `key_bg_elements` 안 `items.properties` 영역 수정:

`orientation` 의 description 교체:
```json
"orientation": {
  "type": "string",
  "description": "Natural-language description tied to directionality_class. For content_surface: which face/side is visible to camera and what is on it. For reflective_surface: what is being reflected and how. For transparent_surface: surface state and what is seen through it. For directional_3d: which side faces camera. For non_directional: empty allowed. content_surface and reflective_surface MUST have a non-empty orientation."
}
```

`items.properties` 에 `directionality_class` 추가:
```json
"directionality_class": {
  "type": "string",
  "enum": ["content_surface", "reflective_surface", "transparent_surface", "directional_3d", "non_directional"],
  "description": "Semantic classification of this element. content_surface = thin object that carries content on one face; which face is visible changes the meaning. reflective_surface = object whose surface reflects content; what is reflected determines meaning. transparent_surface = object the camera sees through; surface state and what lies beyond determine meaning. directional_3d = 3D object with multiple faces where the camera-facing side matters. non_directional = texture/surface/ambient element with no directional meaning. Judge by meaning, not by surface vocabulary or specific examples (non-exhaustive examples, do not classify by this list)."
}
```

`items.required` 에 `"directionality_class"` 추가:
```json
"required": ["element", "state", "orientation", "camera_use", "directionality_class"]
```

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

Edit `prompts/_base/shot_staging/9.${TS}/system.md` — 기존 directional 설명 (4 줄) 을 다음으로 교체:

```diff
- - **방향이 있는 물체**(모니터, 거울, 문, 창문, TV 등)는 카메라에 대한 방향도 명시:
-   - 예: "모니터: 켜짐, 화면이 카메라를 향함" / "모니터: 켜짐, 뒷면만 보임"
-   - 예: "문: 열림, 카메라 쪽으로 열림" / "거울: 인물의 얼굴이 반사되어 보임"
-   - 이 정보가 없으면 이미지 생성 시 물체의 보이는 면이 잘못될 수 있음
+ - **방향성 분류 (directionality_class, 반드시 emit)** — 5 class 중 의미 기반으로 하나 선택:
+   - `content_surface`: 한쪽 면에 콘텐츠가 있는 얇은 표면 객체. 판단 기준 — "이 객체는 두 면 중 한쪽에만 의미 있는 콘텐츠가 있고, 그 면이 카메라에 보이는지에 따라 장면 의미가 달라지는가?"
+   - `reflective_surface`: 반사하는 표면. 판단 기준 — "이 객체가 카메라에 보여주는 것은 자기 자신이 아니라 반사된 다른 콘텐츠인가?"
+   - `transparent_surface`: 투명/반투명 표면. 판단 기준 — "이 객체는 표면 자체보다 그 너머에 무엇이 보이는지가 더 중요한가?"
+   - `directional_3d`: 입체 방향성 객체. 판단 기준 — "이 객체는 면이 여러 개이고 카메라에 대한 정면/측면/뒷면의 차이가 의미를 가지는가?"
+   - `non_directional`: 방향성 무관. 판단 기준 — "이 객체는 어느 방향에서 봐도 장면 의미가 같은가?"
+   - ⚠️ 특정 단어나 사물 이름으로 판단하지 말 것. "이 샷에서 이 element 의 어느 면 / 어떤 방향 / 무엇이 보이는지" 의미를 판단할 것.
+ - **orientation (NL, directionality_class 와 의미 일치)**:
+   - content_surface 면 반드시 작성: 어느 면이 카메라에 보이는지 + 그 면에 무엇이 있는지.
+   - reflective_surface 면 반드시 작성: 표면이 무엇을 반사하는지.
+   - transparent_surface 면 권장: 표면 상태 + 너머에 보이는 것.
+   - directional_3d 면 권장: 카메라에 대한 면 방향.
+   - non_directional 면 자유 (비어도 OK).
+   - ⚠️ content_surface / reflective_surface 의 orientation 빈 값 금지.
```

### - [ ] Step 4: prompt_loader 자동 검출 검증

```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.startswith('9.'), 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.startswith('9.'), f'expected v9, got {found_ver}'

# directionality_class enum 검증
import json
schema = json.load(open(fpath))
props = schema['properties']['shots']['items']['properties']['key_bg_elements']['items']['properties']
assert 'directionality_class' in props, 'directionality_class field 누락'
assert props['directionality_class']['enum'] == [
    'content_surface', 'reflective_surface', 'transparent_surface',
    'directional_3d', 'non_directional',
], 'enum 값 mismatch'
print('OK — v9 pack resolved + directionality_class enum verified')
"
```

Expected: `OK — v9 pack resolved + directionality_class enum verified`.

### - [ ] Step 5: 커밋

```bash
git add prompts/_base/shot_staging/9.*/
git commit -m "$(cat <<'EOF'
feat(prompts): shot_staging v9 — directionality_class enum SOT

Patch C / Area A Task 2 — shot_staging prompt pack v9. schema.json 의
key_bg_elements[] 에 directionality_class enum (5-value) 추가. v8 의
closed-list 형 예시 "monitor, mirror, door, TV, window" 모두 제거 +
5 class 개념 설명 만 (non-exhaustive examples 명시).

system.md 의 ## 배경 중요 요소 섹션 — 방향성 분류 / orientation NL 의
판단 기준을 의미 기반으로 재작성. noun 예시 0.

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

Umbrella spec: Area A.

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

---

## Task 3: shot_staging per-batch validator + retry

**Files**:
- Modify: `backend/app/modules/pipeline/shot_staging.py` (entire batch loop rewrite)
- 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 — Patch C Area A Task 3.

call_structured mock + synthetic shot data. 작품 어휘 0. element 문자열은
generic 한 임의 noun (Patch C 의 어떤 코드도 element 문자열을 분류하지
않음을 록인).
"""
from unittest.mock import patch
import pytest

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


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, directionality_class, 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",
            "directionality_class": directionality_class,
        }],
    }


# G1: content_surface + orientation="" → ShotStagingOrientationError
def test_g1_content_surface_empty_orientation_raises():
    shot_extract, shot_sel, scene_save, entity, vwr = _synthetic_inputs([1])
    mock_response = {"shots": [_shot_with_kbe(91, 1, "any-element", "content_surface", "")]}

    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_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"] == "any-element"
    assert v["directionality_class"] == "content_surface"
    assert v["orientation_raw"] == ""
    assert mock_call.call_count == 3


# G2: reflective_surface + orientation="" → ShotStagingOrientationError
def test_g2_reflective_surface_empty_orientation_raises():
    shot_extract, shot_sel, scene_save, entity, vwr = _synthetic_inputs([1])
    mock_response = {"shots": [_shot_with_kbe(91, 1, "any-element", "reflective_surface", "")]}

    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)

    assert exc_info.value.details["violations"][0]["directionality_class"] == "reflective_surface"
    assert mock_call.call_count == 3


# G3: retry 성공 (1차 빈 → 2차 채워짐)
def test_g3_retry_succeeds_on_second_attempt():
    shot_extract, shot_sel, scene_save, entity, vwr = _synthetic_inputs([1])
    responses = [
        {"shots": [_shot_with_kbe(91, 1, "any", "content_surface", "")]},
        {"shots": [_shot_with_kbe(91, 1, "any", "content_surface", "front face visible to camera")]},
    ]

    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 to camera"
    assert mock_call.call_count == 2

    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"]


# G4: content_surface + orientation 채워짐 → 1 attempt
def test_g4_content_surface_valid_passes_first_attempt():
    shot_extract, shot_sel, scene_save, entity, vwr = _synthetic_inputs([1])
    mock_response = {"shots": [_shot_with_kbe(91, 1, "any", "content_surface", "front face")]}

    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


# G5: reflective_surface + orientation 채워짐 → 1 attempt
def test_g5_reflective_surface_valid_passes_first_attempt():
    shot_extract, shot_sel, scene_save, entity, vwr = _synthetic_inputs([1])
    mock_response = {"shots": [_shot_with_kbe(91, 1, "any", "reflective_surface", "reflects subject's face")]}

    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


# G6: transparent_surface + orientation="" → pass (scope guard)
def test_g6_transparent_surface_empty_passes():
    shot_extract, shot_sel, scene_save, entity, vwr = _synthetic_inputs([1])
    mock_response = {"shots": [_shot_with_kbe(91, 1, "any", "transparent_surface", "")]}

    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


# G7: directional_3d + orientation="" → pass (scope guard, 기존 동작 보존)
def test_g7_directional_3d_empty_passes():
    shot_extract, shot_sel, scene_save, entity, vwr = _synthetic_inputs([1])
    mock_response = {"shots": [_shot_with_kbe(91, 1, "any", "directional_3d", "")]}

    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


# G8: non_directional + orientation="" → pass
def test_g8_non_directional_empty_passes():
    shot_extract, shot_sel, scene_save, entity, vwr = _synthetic_inputs([1])
    mock_response = {"shots": [_shot_with_kbe(91, 1, "any", "non_directional", "")]}

    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


# G9: mixed batch (1 content-empty / 1 reflective-empty / 1 directional_3d-empty / 1 content-ok)
def test_g9_mixed_batch_only_violations_collected():
    shot_extract, shot_sel, scene_save, entity, vwr = _synthetic_inputs([1, 2, 3, 4])
    mock_response = {"shots": [
        _shot_with_kbe(91, 1, "any", "content_surface", ""),
        _shot_with_kbe(91, 2, "any", "reflective_surface", ""),
        _shot_with_kbe(91, 3, "any", "directional_3d", ""),
        _shot_with_kbe(91, 4, "any", "content_surface", "front face"),
    ]}

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

    violations = exc_info.value.details["violations"]
    assert len(violations) == 2
    shot_indices = {v["shot_index"] for v in violations}
    assert shot_indices == {1, 2}


# G10: call_structured 자체 실패 → 기존 failed_batches 누적 (validator 직교)
def test_g10_call_structured_failure_increments_failed_batches():
    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)

    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` (ShotStagingOrientationError 가 Task 1 으로 이미 있으면 G1~G9 가 retry 미동작 → assertion fail).

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

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

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

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

Patch C / Area A — content_surface / reflective_surface element 의
orientation 이 비면 batch retry (max_attempts=3) 후 소진 시
ShotStagingOrientationError raise. validator 는 call_structured try/
except **밖** 에서 실행 — broad except 가 swallow 하지 않도록.
"""
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.prompt_loader import load_prompt, load_schema

logger = logging.getLogger(__name__)

BATCH_SIZE = 10
MAX_ATTEMPTS = 3
ORIENTATION_REQUIRED_CLASSES = ("content_surface", "reflective_surface")


def _find_orientation_violations(shots: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
    """directionality_class 가 orientation 필수 class 인데 orientation 이 빈 entry."""
    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 []:
            cls = el.get("directionality_class") or ""
            orient = (el.get("orientation") or "").strip()
            if cls in ORIENTATION_REQUIRED_CLASSES and not orient:
                out.append({
                    "scene_index": si,
                    "shot_index": shi,
                    "element": el.get("element", ""),
                    "directionality_class": cls,
                    "orientation_raw": el.get("orientation") or "",
                })
    return out


def _format_retry_hint(violations: List[Dict[str, Any]]) -> str:
    lines = [
        "",
        "",
        "[재시도 — 직전 응답에서 다음 element 의 directionality_class 가",
        " 'content_surface' 또는 'reflective_surface' 인데 orientation 이",
        " 비어 있었습니다. 이 두 class 는 어느 면이 보이는지 / 무엇이",
        " 반사되는지 NL 로 반드시 작성하세요:]",
    ]
    for v in violations:
        lines.append(
            f"  - S{v['scene_index']} Shot{v['shot_index']}: "
            f"element='{v['element']}' class='{v['directionality_class']}' "
            f"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 실행 — 선택된 샷별 창의적 연출 분석.

    Patch C / Area A — batch 마다 LLM 응답의 content_surface /
    reflective_surface 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 인덱싱
    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", "")
            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 인덱스 맵
    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 / Area A — 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)
            )

            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

            # ↓ try 외부 — validator raise 가 batch loop 위로 propagate.
            batch_shots = result.get("shots", [])
            violations = _find_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

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

        if batch_failed_at_call:
            continue

    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: 통과 확인

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

Expected: G1~G10 모두 PASS.

### - [ ] Step 5: 기존 shot_staging 회귀

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

Expected: 회귀 0.

### - [ ] 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(area_a): shot_staging per-batch directionality validator + retry

Patch C / Area A Task 3 — content_surface / reflective_surface element 의
orientation 이 빈 경우 batch retry. max_attempts=3 (최초 1 + retry 2)
소진 시 ShotStagingOrientationError raise (HTTP 422).

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

retry user_prompt 에는 violations + "재시도" 한국어 안내가 append.

G1~G10 10 tests PASS.

Umbrella spec: Area A.

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

---

## Task 4: detail_steps.py consumer (helper + 호출부 치환)

**Files**:
- Modify: `backend/app/core/steps/detail_steps.py`
- 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 tests — Patch C Area A Task 4.

5-class directive branching. element 문자열 자체는 어떤 임의 noun 이어도
directionality_class enum 만으로 동작 (G25 록인).
"""
import pytest

from app.core.steps.detail_steps import _build_bg_element_line


# G16: content_surface + orientation non-empty → directive 포함
def test_g16_content_surface_directive():
    line = _build_bg_element_line(
        element="any-element",
        state="held",
        camera_use="foreground",
        orientation="front face faces camera with content visible",
        directionality_class="content_surface",
    )
    assert "any-element" in line
    assert "[방향: front face faces camera with content visible]" in line
    assert "content-bearing surface" in line
    assert "front face faces camera with content visible" in line.split("\n")[-1]


# G17: reflective_surface + orientation non-empty → directive 포함
def test_g17_reflective_surface_directive():
    line = _build_bg_element_line(
        element="any-element",
        state="mounted",
        camera_use="frame edge",
        orientation="reflects subject's face from left",
        directionality_class="reflective_surface",
    )
    assert "reflecting surface" in line
    assert "reflects subject's face from left" in line
    assert "content-bearing surface" not in line


# G18: content_surface + orientation="" → directive 0 (defensive)
def test_g18_content_surface_empty_no_directive():
    line = _build_bg_element_line(
        element="any-element",
        state="held",
        camera_use="foreground",
        orientation="",
        directionality_class="content_surface",
    )
    assert "[방향:" not in line
    assert "content-bearing surface" not in line
    assert "reflecting surface" not in line


# G19: reflective_surface + orientation="" → directive 0 (defensive)
def test_g19_reflective_surface_empty_no_directive():
    line = _build_bg_element_line(
        element="any-element",
        state="mounted",
        camera_use="frame edge",
        orientation="",
        directionality_class="reflective_surface",
    )
    assert "[방향:" not in line
    assert "reflecting surface" not in line


# G20: transparent_surface + orientation non-empty → directive 0 (scope guard)
def test_g20_transparent_surface_no_directive():
    line = _build_bg_element_line(
        element="any-element",
        state="clean",
        camera_use="see-through",
        orientation="surface clean; subject visible behind it",
        directionality_class="transparent_surface",
    )
    assert "[방향: surface clean; subject visible behind it]" in line
    assert "content-bearing surface" not in line
    assert "reflecting surface" not in line


# G21: directional_3d + orientation non-empty → directive 0 (기존 동작 보존)
def test_g21_directional_3d_no_directive():
    line = _build_bg_element_line(
        element="any-element",
        state="powered on",
        camera_use="background",
        orientation="front face toward camera",
        directionality_class="directional_3d",
    )
    assert "[방향: front face toward camera]" in line
    assert "content-bearing surface" not in line


# G22: non_directional → directive 0
def test_g22_non_directional_no_directive():
    line = _build_bg_element_line(
        element="any-element",
        state="rain-slick",
        camera_use="texture",
        orientation="weathered surface",
        directionality_class="non_directional",
    )
    assert "[방향: weathered surface]" in line
    assert "content-bearing surface" not in line


# G23: legacy cp (directionality_class 누락) → directive 0
def test_g23_legacy_no_class_no_directive():
    line = _build_bg_element_line(
        element="any-element",
        state="held",
        camera_use="foreground",
        orientation="front face visible",
        directionality_class="",
    )
    assert "[방향: front face visible]" in line
    assert "content-bearing surface" not in line


# G24: unknown enum value → directive 0 (defensive)
def test_g24_unknown_enum_no_directive():
    line = _build_bg_element_line(
        element="any-element",
        state="held",
        camera_use="foreground",
        orientation="some orient",
        directionality_class="future_unknown_class",
    )
    assert "[방향: some orient]" in line
    assert "content-bearing surface" not in line
    assert "reflecting surface" not in line


# G25: element 문자열 무관 architecture 록인
@pytest.mark.parametrize(
    "element",
    [
        "snapshot",             # FLAT_NOUNS_EN 에 없던 단어
        "polaroid",             # 동의어
        "부적",                  # 한국어 작품 prop 묘사
        "monitor",              # 3D fixture noun
        "wooden deck",          # 비디렉셔널 noun
        "",                     # 빈 element
        "12345",                # 숫자
    ],
)
def test_g25_element_string_independent_when_class_emitted(element):
    """code 가 element 문자열 자체를 분류하지 않음을 록인.

    어떤 임의 element 라도 directionality_class="content_surface" 면
    directive 가 동작해야 한다.
    """
    line = _build_bg_element_line(
        element=element,
        state="state",
        camera_use="use",
        orientation="front face visible",
        directionality_class="content_surface",
    )
    assert "content-bearing surface" in line


# G26: orientation NL inline 은 모든 class 공통
def test_g26_orientation_inline_universal():
    for cls in ["content_surface", "reflective_surface", "transparent_surface",
                "directional_3d", "non_directional", "", "unknown"]:
        line = _build_bg_element_line(
            element="any",
            state="state",
            camera_use="use",
            orientation="some orientation",
            directionality_class=cls,
        )
        assert "[방향: some orientation]" in line, f"missing for class={cls!r}"
```

### - [ ] 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'`.

### - [ ] Step 3: helper + 호출부 치환

Edit `backend/app/core/steps/detail_steps.py`:

**3a.** module-level helper 추가 (다른 `def _xxx` helper 들 근처, `def _build_entity_traits_block` (L828) 직전 또는 직후):

```python
# Patch C / Area A — enum literal → directive template 매핑.
# code 가 element 문자열 분류 0. {element} 와 {orientation} 만 substitution.
_DIRECTIVE_TEMPLATES = {
    "content_surface": (
        "Orientation constraint: the content-bearing surface of the "
        "{element} is visible as follows — {orientation}. Render exactly "
        "this face; do not invent content on a face that is not visible "
        "to the camera."
    ),
    "reflective_surface": (
        "Reflection constraint: the reflecting surface of the {element} "
        "shows — {orientation}. Render this reflection accurately; do not "
        "invent other reflections or scenes."
    ),
    # transparent_surface / directional_3d / non_directional / 미지 enum
    # 모두 영어 directive 0 (legacy NL inline 만).
}


def _build_bg_element_line(
    element: str,
    state: str,
    camera_use: str,
    orientation: str,
    directionality_class: str = "",
) -> str:
    """user_prompt 의 'bg element' 한 줄 + 5-class directive 분기.

    Contract:
      - orientation NL non-empty → "[방향: {orientation}]" inline (모든
        class 공통, 기존 동작 보존).
      - directionality_class in _DIRECTIVE_TEMPLATES (content_surface /
        reflective_surface) AND orientation non-empty → 강한 영어
        directive 를 다음 줄에 추가.
      - 그 외 (transparent_surface / directional_3d / non_directional /
        legacy cp 의 class 누락 / 미지 enum value) → 영어 directive 0.
      - **code 가 element 문자열을 분류하지 않음** — directionality_class
        enum literal 만 본다. element 가 어떤 noun 이든 무영향.
    """
    base = f"  - {element}: {state} ({camera_use})"
    if orientation and orientation.strip():
        base += f" [방향: {orientation}]"
    template = _DIRECTIVE_TEMPLATES.get(directionality_class)
    if template and orientation and orientation.strip():
        base += "\n    " + template.format(element=element, orientation=orientation)
    return base
```

**3b.** L2128-L2136 inline 을 helper 호출로 교체:

```diff
  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)
+         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", ""),
+             directionality_class=e.get("directionality_class", ""),
+         ))
      user_prompt += f"배경 핵심 요소:\n" + "\n".join(bg_lines) + "\n"
```

### - [ ] Step 4: 통과 확인

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

Expected: G16~G26 모두 PASS (G25 의 7 parametrized case 포함).

### - [ ] Step 5: 기존 detail_steps / scene_detail 회귀

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

Expected: 회귀 0. prompt_hash 가 바뀌는 건 의도된 hash-chain trigger.

### - [ ] Step 6: 커밋

```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(area_a): consumer directive branching by directionality_class

Patch C / Area A Task 4 — _DIRECTIVE_TEMPLATES literal 상수 (5-class enum
→ English directive 매핑) + _build_bg_element_line module-level helper +
detail_steps.py L2128 inline 치환.

content_surface / reflective_surface + orientation non-empty 일 때만
강한 영어 directive 가 다음 줄에 추가. transparent_surface /
directional_3d / non_directional / legacy / unknown 모두 기존 동작 보존.

핵심 — code 가 element 문자열을 분류하지 않음. G25 의 7 parametrized
case (snapshot / polaroid / 부적 / monitor / wooden deck / 빈 / 숫자)
모두 directionality_class enum 만으로 directive 동작 록인.

scene_detail user_prompt 포맷 변경 → prompt_hash 변경 (downstream cp
invalidation 정상 동작).

G16~G26 PASS.

Umbrella spec: Area A.

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

---

## Task 5: Semantic Regex Ban Gate (umbrella §3.1) 첫 도입

본 Area A 안에서 Gate 1 의 changed-lines-only pytest static gate 신설. legacy 전체 회귀 차단.

**Files**:
- Create: `backend/tests/_gate/__init__.py`
- Create: `backend/tests/_gate/test_semantic_regex_ban.py`

### - [ ] Step 1: gate 디렉토리 + __init__.py

```bash
mkdir -p backend/tests/_gate
touch backend/tests/_gate/__init__.py
```

### - [ ] Step 2: gate 테스트 작성

Create `backend/tests/_gate/test_semantic_regex_ban.py`:

```python
"""Gate 1 — Semantic Regex Ban (umbrella spec §3.1).

production diff (backend/app/**/*.py) 의 changed lines 에서 open-world
visual / story semantic classifier 목적의 `_NOUNS / _TOKENS / _KEYWORDS /
_PHRASES` 식별자 **신규 추가** 시 review blocker.

Allowlist (closed-world 허용):
- ID regex (C##, P##, L##, O##, C##O##) — 식별자 이름에 ID 명시
- status enum / safety / closed system enum (이름에 SAFETY/STATUS/PHASE 등 포함)

CI 단계:
- 1차 (본 file): pytest changed-lines-only static gate.
- 2차: review checklist.
- 나중: pre-commit hook.
"""
import re
import subprocess
import sys
from pathlib import Path

import pytest


_REPO_ROOT = Path(__file__).resolve().parents[3]
_FORBIDDEN_PATTERN = re.compile(
    r"\b_[A-Z][A-Z0-9_]*(?:NOUNS|TOKENS|KEYWORDS|PHRASES)\b"
)
# Allowlist — 본 식별자에 다음 substring 포함 시 closed-world 로 간주, 허용.
_ALLOWLIST_SUBSTR = (
    "SAFETY",       # safety policy rewrite (Area I SPECIAL)
    "STATUS",       # status enum
    "PHASE",        # phase enum
    "SCHEMA",       # schema field name token
    "POLICY",       # policy literal (closed system)
    "ID_",          # ID 관련 (closed)
    "_ID",          # ID 관련 (closed)
    "VERSION",      # version
    "HASH",         # hash
    "PATH",         # path
)


def _diff_changed_lines() -> list[tuple[str, int, str]]:
    """git diff main...HEAD 의 added lines 만 반환. [(file, lineno, line), ...]."""
    try:
        out = subprocess.check_output(
            ["git", "diff", "main...HEAD", "--unified=0", "--no-color",
             "--", "backend/app/**/*.py"],
            cwd=_REPO_ROOT,
            stderr=subprocess.DEVNULL,
        ).decode("utf-8", errors="replace")
    except subprocess.CalledProcessError:
        return []

    out_lines = out.splitlines()
    result: list[tuple[str, int, str]] = []
    current_file = None
    current_lineno = 0
    for line in out_lines:
        if line.startswith("+++ b/"):
            current_file = line[len("+++ b/"):]
        elif line.startswith("@@"):
            m = re.match(r"@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@", line)
            if m:
                current_lineno = int(m.group(1))
        elif line.startswith("+") and not line.startswith("+++"):
            if current_file:
                result.append((current_file, current_lineno, line[1:]))
            current_lineno += 1
        elif not line.startswith("-"):
            current_lineno += 1
    return result


def _is_allowlist_identifier(ident: str) -> bool:
    return any(substr in ident for substr in _ALLOWLIST_SUBSTR)


def test_semantic_regex_ban_gate():
    """changed lines 에서 forbidden 식별자 신규 추가가 있으면 fail."""
    changed = _diff_changed_lines()
    if not changed:
        pytest.skip("no changes against main detected (running outside PR context)")

    violations: list[tuple[str, int, str, str]] = []
    for file_path, lineno, line in changed:
        # comment / docstring 제외 (간단하게 # 또는 트리플쿼터 안 라인은 skip)
        stripped = line.lstrip()
        if stripped.startswith("#"):
            continue
        for m in _FORBIDDEN_PATTERN.finditer(line):
            ident = m.group(0)
            if _is_allowlist_identifier(ident):
                continue
            violations.append((file_path, lineno, ident, line.rstrip()))

    if violations:
        msgs = []
        for f, ln, ident, src in violations:
            msgs.append(f"  {f}:{ln}: {ident!r} in `{src.strip()[:80]}`")
        pytest.fail(
            "Gate 1 (Semantic Regex Ban) — open-world semantic classifier "
            "noun/token list 신규 추가 감지. allowlist 가 아니면 LLM SOT 로 "
            "이동. umbrella spec §3.1.\n" + "\n".join(msgs)
        )
```

### - [ ] Step 3: gate 통과 확인 (현재 patch 의 changed lines 에서 violation 없음)

```bash
cd backend
pytest tests/_gate/test_semantic_regex_ban.py -v
```

Expected: PASS (Area A 의 diff 안 `_NOUNS / _TOKENS / _KEYWORDS / _PHRASES` 신규 0).

만일 fail 하면 즉시 review — 위치 / 식별자명 확인.

### - [ ] Step 4: 커밋

```bash
git add backend/tests/_gate/
git commit -m "$(cat <<'EOF'
feat(gate): introduce Semantic Regex Ban gate (umbrella spec §3.1)

Patch C / Area A Task 5 — Gate 1 의 첫 도입. pytest changed-lines-only
static gate. production diff 안 open-world visual/story semantic
classifier 목적의 _NOUNS / _TOKENS / _KEYWORDS / _PHRASES 식별자 신규
추가 시 fail.

Allowlist (closed-world 허용): SAFETY / STATUS / PHASE / SCHEMA / POLICY /
ID_ / _ID / VERSION / HASH / PATH substring 포함 식별자.

CI 단계 (umbrella §3.5):
- 1차 (본 gate): pytest changed-lines-only.
- 2차: review checklist (Area A 이후).
- 나중: pre-commit hook (legacy 정리 후).

Umbrella spec: docs/superpowers/specs/2026-05-12-llm-structured-sot-migration-design.md

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

---

## Task 6: residue grep gate + broader regression

본 Area A 의 변경 안에 시나리오 어휘 / v1.0~v1.2 폐기된 식별자 잔재 0 검증 + 전체 회귀 0.

### - [ ] Step 1: scenario residue grep

```bash
# closed-world ID prefix (C##/L##/P##/O##/S##) 는 codebase 의 closed-world
# contract — scenario residue 가 아님. 시나리오 인물명 / 장소명 / 시각
# 어휘만 검사.
SCENARIO_FORBIDDEN='수리영|혜수|민숙|인우|금월도|시골 식당|둥근 원|두 소녀|옥탑방|조타실|갑판|낡은 흑백|흑백 사진|낡은 사진|낡은 액자'

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

Expected: **출력 없음** (zero-hit).

### - [ ] Step 2: v1.0~v1.2 폐기 식별자 residue grep

```bash
LEGACY_FORBIDDEN='FLAT_NOUNS_EN|FLAT_NOUNS_KO|PRINTED_PAGE_NOUNS|VISUAL_DISPLAY_NOUNS|flat_directional_classifier|is_flat_directional|directionality_kind|camera_visible_surface|flat_content_surface|spatial_directional_object'

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

Expected: **출력 없음**.

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

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

Expected:
- Area A 신규 테스트 (Task 1: 5 + Task 3: 10 + Task 4: G16~G26 = 약 17 + Task 5: 1) 모두 PASS.
- 회귀 0 (pre-existing carry — `test_analysis_dispatch_service.py::test_select_steps_for_category_planning_doc_inclusion` 1건, 우선순위 #4 직교).

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

```
Patch C / Area A implementation 완료:
- 신규 에러: ShotStagingOrientationError
- 신규 enum SOT: shot_staging.key_bg_elements[].directionality_class (5-value)
- 신규 prompt v9: noun 예시 0, 개념 설명만
- validator + retry: shot_staging.py (max_attempts=3)
- consumer helper: detail_steps.py _build_bg_element_line + 호출부 치환
- Gate 1 (Semantic Regex Ban) 첫 도입: tests/_gate/

테스트 결과:
- 신규: ~30 PASS
- 회귀: 0 (pre-existing 1건 외)

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

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

검증 only.

---

## Task 7: Manual canary (코드 review 통과 후 운영자 수동)

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"

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;"
```

### - [ ] Step 2: shot_staging force re-run

```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: ~6 batches, ShotStagingOrientationError raise 0.

### - [ ] Step 3: cp diff 검증

```bash
python3 -c "
import json
mf = './projects/02829fe8-af47-4dda-9cfe-af9457a4cd5b/checkpoints/episodes/fc38cf03-3863-4cdb-936a-3ef99438242c/shot_staging/manifest.json'
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]
for el in m[0].get('key_bg_elements', []):
    print(f'element={el[\"element\"]!r}  class={el.get(\"directionality_class\", \"\")!r}  orient={el.get(\"orientation\", \"\")!r}')
"
```

Expected: photograph 가 등장한 element 의 `class == "content_surface"` + `orient` 비어있지 않음.

### - [ ] Step 4: scene_detail force re-run

```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"
```

또는 단건 redo:
```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}"
```

### - [ ] Step 5: t2i_prompt 검증

```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]
for var in m[0].get('t2i_variations', []):
    print('---')
    print(var['t2i_prompt'])
"
```

Expected: `"Orientation constraint: the content-bearing surface of the [element] is visible as follows — ..."` 또는 비슷한 영어 directive 등장.

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

```bash
STILL_ID="<S26/6 still_id>"
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}"
```

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

생성 PNG 확인 — 사진 앞면 (인쇄면) 이 카메라에 보임. 뒷면 / 백지 X.

**PASS** → Area A closure. 다음 Area (B, C, D ...) 진입.
**FAIL** → prompt v9 의 개념 설명 보강 또는 directive wording 개선 → v1.1 escalate.

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

```
Patch C / Area A canary 결과:
- shot_staging force re-run: PASS / FAIL
- scene_detail force re-run: PASS / FAIL
- still image regen: PASS / FAIL — 사진 앞면 카메라 노출 여부
```

---

## Self-review

### Spec coverage

| Umbrella spec § | 구현 task |
|---|---|
| §1 원칙 (open vs closed-world) | 전 task 가 따름. element 문자열 분류 0 = Task 4 G25 록인 |
| §2 inventory Area A | Task 1~6 모두 |
| §3.1 Gate 1 (Semantic Regex Ban) | Task 5 |
| §3.2 Gate 2 (Prompt Closed-List) | Task 2 의 v9 prompt 가 준수 (noun 예시 0) |
| §3.3 Gate 3 (Structured SOT Required) | Task 2 의 directionality_class enum 신설 |
| §3.4 Gate 4 (No Silent Fallback) | Task 4 의 consumer 가 legacy/unknown 일 때 directive 0 (silent fallback X) |
| §3.5 CI 통합 순서 1차 | Task 5 의 pytest changed-lines-only gate |
| §4 Patch ordering Area A 첫 진입 | 본 plan 자체 |
| §5 모델 가설 (gemini-pro) | Task 2 의 prompt v9 가 gemini-pro 의 schema 정합. micro-benchmark 는 별도 단계 |

### Placeholder scan

`TBD` / `TODO` / `placeholder` 0.

### Type consistency

- `ORIENTATION_REQUIRED_CLASSES = ("content_surface", "reflective_surface")` (shot_staging.py) ↔ `_DIRECTIVE_TEMPLATES` keys (detail_steps.py) — 같은 enum literal 사용. Task 3 + Task 4 가 같은 literal 참조.
- `ShotStagingOrientationError(*, batch_num, total_batches, attempts, violations)` Task 1 정의 + Task 3 raise + Task 1 shape test + Task 3 G1/G2/G9 assertion 모두 동일 kwargs.

---

## Execution Handoff

Plan complete. 본 plan 의 다음 진행:

**1. Subagent-Driven Development (recommended)** — Fresh subagent per task + two-stage review (spec compliance / code quality). 사용자가 Codex review 는 수동 paste.

**2. Inline Execution** — 본 session 안에서 batch (executing-plans).

각 task 마다 commit gate 명확. CI gate 1 (Task 5) 가 Area A 안에서 첫 enforcement 도입 = 본 patch 가 새 원칙의 living example.
