# 야외 3레인 Stage C — 레인2 spatial view cluster DAG Implementation Plan

> **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:** 레인2(structure_plate) 수직 슬라이스 — LLM spatial view cluster plan(DAG) → root=확정 플레이트 씨드 → cluster 배경 순차 생성 → 스틸(콘티 무사용, background-only prev) canary.

**Architecture:** ① 신규 `outdoor_view_cluster` 모듈이 그룹의 structure_plate 샷들을 spatial view cluster로 군집하는 LLM 계약(fail-closed 스키마·validator)과 결정론 DAG 순서(topo)를 담당 — 군집 기준은 같은 고정 구조의 겹치는 면/개구부/레벨/접근축 공유(샷 번호·인물·시간 유사도 금지, 합의 결정 4). ② root cluster 배경=해당 구조물의 기존 확정 플레이트(plate_multiroll `_sel`) 재사용, 비-root cluster 배경=LLM 저작 `bg_prompt_en`+[root 씨드(+겹치는 부모 cluster 배경 추가 씨드)] multiroll 생성. ③ 스틸은 [cluster 배경+엔티티 passport(+같은 cluster 직전 still=`previous_background_only` role)] — 맵·콘티 참조 0.

**Tech Stack:** litellm call_structured(멀티모달 — 기존 확정 플레이트 첨부), nb2+multiroll_select(cluster 배경·스틸), pytest(결정론만).

## Global Constraints

- **LLM 전달 데이터 절대 자르지 않음** (씬 원문 전문, `[:N]` 금지 — CLAUDE.md 절대 규칙)
- **시나리오 의존 코딩 금지**: rule/prompt/code에 작품 고유명 0, fixture=`SAMPLE_*`
- **글자/substring 의미 판단 금지**: 군집 판단=LLM structured only. 코드는 완전성·DAG 무결성만 검증
- **TDD는 deterministic 영역만**: 스키마/validator/topo/프롬프트 조립만 유닛. 군집·배경·스틸 완성도=canary 육안
- **프롬프트 팩=신규 버전 디렉토리** `prompts/_base/<module>/N.YYYYMMDDHHmm/` (리포 루트, `.md`)
- **맵·콘티 참조 0** (레인2 acceptance): cluster 배경·스틸 어느 단계에도 사이트 맵/마커 맵/콘티를 참조로 넣지 않는다
- **root→cluster→still 체인**: cluster 배경은 반드시 root 씨드 경유, 스틸은 cluster 배경 경유 (acceptance)
- **prev=background-only role**: "고정 배경만, 인물·포즈·구도·시간·조명 복사 금지, 현재 본문이 시간/조명 SOT" (합의 결정 4)
- **canary 러너·갤러리=scratchpad, 커밋 금지**. 스텝/디스패치 배선=Stage D
- **모델 분업**: cluster plan 저작=gpt(`project_config={"outdoor_view_cluster": {"model": "gpt"}}` 명시 — 미등록 스텝 gemini-pro silent fallback 차단, Stage B 교훈), 판정=gemini-pro, 배경·스틸=nb2
- **헤드리스 러너=반드시 backend cwd** (.env 로드 cwd 의존 — Stage B 교훈)
- Stage D 배선 조건(Codex Stage B 비차단 메모)과 동형 유지: 팩 버전·모델은 Stage D에서 config hash 스탬프

## 참조 컨텍스트 (implementer가 알아야 할 기존 계약)

- **Stage A 산출**(5회차, `outdoor_lane_plan/manifest.json`): `data.groups[gid].plan.shot_bindings[]`에서 `lane=="structure_plate"`인 샷이 레인2 대상. 실측: bg_rooftop_housing_complex 6샷(S4sh1/S10sh6/S10sh7/S11sh4/S11sh5/S17sh2), bg_fishing_boat 2샷(S28sh5/S28sh8)
- **기존 확정 플레이트**: `shot_conti_light.resolve_shot_plate_map(projects_dir, project_id, episode_id) -> Dict["si_shi", Path]` — 실측: 4_1→L03B02.png, 10_6/10_7→L03B03.png, 11_4/11_5→L11B01.png (plate_multiroll 3롤+판정+수정 완료본)
- **multiroll 프리미티브**: `multiroll_select.run_multiroll_select(tag, prompt, labeled_refs, out_stem, gen_fn, judge_fn, critique_fn, fix_gen_fn, roll_count, critique_enabled, fix_head/fix_tail/fix_label, record, persist_record_fn)`; `multiroll_gemini.make_nb2_gen_fn/make_gemini_judge_fn/make_gemini_critique_fn/resolve_judge_texts(roll_count, judge_name=...)`, `multiroll_select.build_judge_schema(roll_labels(n))/build_critique_schema()`. plate 계열 judge_name은 구현 시 `plate_multiroll` 호출부를 grep해 동일 텍스트 재사용(없으면 "judge_still")
- **스틸 조립**: `still_recipe.build_still_prompt(shot_desc, place_text, time_of_day_en, world_anchor, bg_only, prev_used, prev_usage_en, pose_clauses, movement_en, figures_en, carried_en, char_names, prompt_version)` — 콘티 없는 레인2 스틸도 그대로 사용(prev_used/prev_usage_en로 background-only 절 주입)
- **LLM 러너 뼈대**: `outdoor_marker_map.run_marker_geometry_shot`(Stage B)와 동일 패턴 — load_prompt(version pin)+멀티모달 parts+위반 힌트 재시도+AppError
- **Stage B 교훈**: 샷 서술 SOT=shot_validator(staging엔 없음 — description/characters 병합), 연출 카메라 메모=shot_staging.camera_direction

## 이 플랜의 파일 구조

- `backend/app/modules/pipeline/outdoor_view_cluster.py` — 스키마·validator·topo 순서·배경 프롬프트 조립·LLM 러너
- `prompts/_base/outdoor_view_cluster/1.202607150600/` — cluster plan 저작 계약(system/user_template)
- `prompts/_base/cluster_bg/1.202607150610/` — cluster 배경 생성 계약(seed_clause/parent_clause/no_people)
- `backend/tests/pipeline/test_outdoor_view_cluster.py` — 결정론 테스트
- `<scratchpad>/run_lane2_canary.py`, `<scratchpad>/build_lane2_gallery.py` — canary 러너·갤러리(커밋 금지)

## 설계 확정 사항 (이 플랜의 해석 — 갤러리·Codex 리뷰에 명시 공유)

- **generation_order는 LLM 저작 필드에서 제외** — 합의 결정 4의 산출 목록에 있으나, parent DAG에서 결정론 topo로 유도하는 것이 안전(LLM이 순서를 틀려도 코드가 SOT). validator가 DAG 무결성(root 정확히 1, 순환·자기참조 금지)을 잠근다.
- **root cluster 배경=기존 확정 플레이트 재사용**(신규 생성 0) — canary 범위. root cluster에 대응하는 플레이트는 멤버 샷들의 `resolve_shot_plate_map` 최빈 플레이트. Stage D에서 root plate 생성 경로(plate_multiroll)와 배선.
- **비-root cluster의 bg_prompt_en은 cluster plan LLM이 함께 저작** — 군집 근거(stable_visible_features/spatial_scope)와 배경 서술이 한 계약 안에 있어야 정합(별도 호출 분리는 YAGNI).
- **prev(스틸)=같은 cluster의 스토리 순서상 직전 완료 still** — 레인1의 same-segment 근사와 동형. Stage D에서 '실제 겹침' 정책으로 통합 교체.

---

### Task C1: cluster plan 스키마 + fail-closed validator + 결정론 topo 순서

**Files:**
- Create: `backend/app/modules/pipeline/outdoor_view_cluster.py`
- Test: `backend/tests/pipeline/test_outdoor_view_cluster.py`

**Interfaces:**
- Produces:
  - `MAX_CLUSTERS = 6`, `_ALLOWED_CLUSTER_IDS = ("CL1", ..., "CL6")`
  - `build_cluster_schema(shot_keys: list[str], max_clusters: int = MAX_CLUSTERS) -> dict` — clusters[]: cluster_id(enum CL1..), member_shot_keys(enum=대상 샷 키), spatial_scope_en, stable_visible_features_en[], view_axis_family_en, overlap_evidence_en, parent_cluster_id(enum CL1..+null), bg_prompt_en, rationale_ko
  - `validate_cluster_plan(plan: Any, expected_shot_keys: list[str]) -> list[str]` — 멤버 완전성(전 샷 정확히 1회)/cluster_id 중복/root 정확히 1(parent null)/parent 유효·자기참조·순환 금지/비-root bg_prompt_en 필수·root는 금지/features 1개 이상/타입·bool 배제 자체 완결
  - `generation_order(plan: dict) -> list[str]` — root 먼저, parent 앞 topo(동레벨=cluster_id 정렬), 결정론

- [ ] **Step 1: 실패 테스트 작성** — `backend/tests/pipeline/test_outdoor_view_cluster.py`

```python
"""outdoor_view_cluster 결정론 테스트 — 스키마/validator/topo만.

LLM 군집 완성도는 검증하지 않는다. fixture 전부 시나리오 중립 SAMPLE.
"""
import pytest

from app.modules.pipeline.outdoor_view_cluster import (
    MAX_CLUSTERS,
    build_cluster_schema,
    generation_order,
    validate_cluster_plan,
)

KEYS = ["S3_Shot1", "S3_Shot2", "S5_Shot1"]


def _cluster(cid, members, parent, bg, **over):
    base = {
        "cluster_id": cid,
        "member_shot_keys": members,
        "spatial_scope_en": "front yard and facade of the main structure",
        "stable_visible_features_en": ["brick facade", "steel entry door"],
        "view_axis_family_en": "frontal approach axis",
        "overlap_evidence_en": "both views share the same facade wall",
        "parent_cluster_id": parent,
        "bg_prompt_en": bg,
        "rationale_ko": "SAMPLE 근거",
    }
    base.update(over)
    return base


def _plan(**over):
    base = {"clusters": [
        _cluster("CL1", ["S3_Shot1", "S3_Shot2"], None, ""),
        _cluster("CL2", ["S5_Shot1"], "CL1",
                 "the same structure seen from the side alley"),
    ]}
    base.update(over)
    return base


def test_schema_locks_ids_and_member_enum():
    s = build_cluster_schema(KEYS)
    c = s["properties"]["clusters"]["items"]["properties"]
    assert c["cluster_id"]["enum"] == [
        f"CL{i}" for i in range(1, MAX_CLUSTERS + 1)]
    assert c["member_shot_keys"]["items"]["enum"] == KEYS
    assert None in c["parent_cluster_id"]["enum"]


def test_validate_passes_clean():
    assert validate_cluster_plan(_plan(), KEYS) == []


def test_validate_rejects_missing_or_duplicate_member():
    p = _plan()
    p["clusters"][1]["member_shot_keys"] = []  # S5_Shot1 누락
    out = validate_cluster_plan(p, KEYS)
    assert any("누락" in v for v in out)
    p2 = _plan()
    p2["clusters"][1]["member_shot_keys"] = ["S3_Shot1", "S5_Shot1"]  # 중복 소속
    assert any("중복" in v for v in validate_cluster_plan(p2, KEYS))


def test_validate_rejects_bad_root_count_and_cycle():
    p = _plan()
    p["clusters"][0]["parent_cluster_id"] = "CL2"  # root 0 + 순환
    out = validate_cluster_plan(p, KEYS)
    assert any("root" in v for v in out)
    p2 = _plan()
    p2["clusters"][1]["parent_cluster_id"] = None  # root 2개
    assert any("root" in v for v in validate_cluster_plan(p2, KEYS))
    p3 = _plan()
    p3["clusters"][1]["parent_cluster_id"] = "CL2"  # 자기 참조
    assert any("자기" in v or "순환" in v
               for v in validate_cluster_plan(p3, KEYS))


def test_validate_rejects_unknown_parent_and_dup_id():
    p = _plan()
    p["clusters"][1]["parent_cluster_id"] = "CL9"
    assert any("parent" in v for v in validate_cluster_plan(p, KEYS))
    p2 = _plan()
    p2["clusters"][1]["cluster_id"] = "CL1"
    assert any("중복" in v for v in validate_cluster_plan(p2, KEYS))


def test_validate_bg_prompt_rules():
    p = _plan()
    p["clusters"][1]["bg_prompt_en"] = ""       # 비-root 인데 없음
    assert any("bg_prompt" in v for v in validate_cluster_plan(p, KEYS))
    p2 = _plan()
    p2["clusters"][0]["bg_prompt_en"] = "should not be here"  # root 인데 있음
    assert any("root" in v and "bg_prompt" in v
               for v in validate_cluster_plan(p2, KEYS))


def test_validate_rejects_malformed_root_and_types():
    assert validate_cluster_plan([], KEYS) != []          # root 가 dict 아님
    p = _plan()
    p["clusters"] = [None, "x"]
    out = validate_cluster_plan(p, KEYS)
    assert out and all(isinstance(v, str) for v in out)
    p2 = _plan()
    p2["clusters"][0]["stable_visible_features_en"] = []
    assert any("features" in v for v in validate_cluster_plan(p2, KEYS))


def test_generation_order_topo_and_deterministic():
    p = {"clusters": [
        _cluster("CL3", ["S5_Shot1"], "CL1", "side view"),
        _cluster("CL1", ["S3_Shot1"], None, ""),
        _cluster("CL2", ["S3_Shot2"], "CL1", "rear view"),
    ]}
    assert generation_order(p) == ["CL1", "CL2", "CL3"]
    assert generation_order(p) == generation_order(p)  # 결정론
```

- [ ] **Step 2: 실패 확인**

Run: `cd /Users/manta/Documents/Projects/TheRoad-I1/backend && .venv/bin/pytest tests/pipeline/test_outdoor_view_cluster.py -v`
Expected: FAIL (`ModuleNotFoundError`)

- [ ] **Step 3: 구현** — `backend/app/modules/pipeline/outdoor_view_cluster.py`

```python
"""레인2 spatial view cluster — 스키마·검증·DAG 순서 (설계 v2 Stage C).

군집 기준(합의 결정 4): 같은 고정 구조의 겹치는 면/개구부/레벨/접근축
공유 — 샷 번호·인물·시간 유사도 금지. LLM 은 군집 구조 JSON 만 저작,
코드는 완전성·DAG 무결성 fail-closed 검증과 결정론 topo 순서만 담당.
generation_order 는 LLM 저작이 아니라 parent DAG 에서 코드가 유도한다.
"""
from __future__ import annotations

from typing import Any, Dict, List

MAX_CLUSTERS = 6
_ALLOWED_CLUSTER_IDS = tuple(f"CL{i}" for i in range(1, MAX_CLUSTERS + 1))

_MODULE = "outdoor_view_cluster"

PROMPT_VERSION_MAP = {
    "1": "1.202607150600",
}


def resolve_prompt_version(version: str) -> str:
    if version not in PROMPT_VERSION_MAP:
        raise ValueError(f"outdoor_view_cluster 프롬프트 버전 없음: {version}")
    return PROMPT_VERSION_MAP[version]


def build_cluster_schema(
    shot_keys: List[str], max_clusters: int = MAX_CLUSTERS
) -> Dict[str, Any]:
    """cluster plan 스키마 — cluster_id/member/parent enum 잠금."""
    ids = [f"CL{i}" for i in range(1, max_clusters + 1)]
    cluster = {
        "type": "object",
        "properties": {
            "cluster_id": {"enum": ids},
            "member_shot_keys": {
                "type": "array",
                "items": {"enum": list(shot_keys)},
                "minItems": 1,
            },
            "spatial_scope_en": {"type": "string", "minLength": 8},
            "stable_visible_features_en": {
                "type": "array",
                "items": {"type": "string", "minLength": 3},
                "minItems": 1,
            },
            "view_axis_family_en": {"type": "string", "minLength": 5},
            "overlap_evidence_en": {"type": "string", "minLength": 8},
            "parent_cluster_id": {"enum": ids + [None]},
            "bg_prompt_en": {"type": "string"},
            "rationale_ko": {"type": "string", "minLength": 5},
        },
        "required": [
            "cluster_id", "member_shot_keys", "spatial_scope_en",
            "stable_visible_features_en", "view_axis_family_en",
            "overlap_evidence_en", "parent_cluster_id", "bg_prompt_en",
            "rationale_ko",
        ],
        "additionalProperties": False,
    }
    return {
        "type": "object",
        "properties": {
            "clusters": {"type": "array", "items": cluster,
                         "minItems": 1, "maxItems": max_clusters},
        },
        "required": ["clusters"],
        "additionalProperties": False,
    }


def validate_cluster_plan(
    plan: Any, expected_shot_keys: List[str]
) -> List[str]:
    """결정론 완전성·DAG 무결성 검증 — 위반 리스트 반환 (fail-closed).

    스키마가 정상 경로를 잠그더라도 public 소비자(topo/배경 생성)가 이
    validator 를 직접 신뢰하므로 자체 완결 (Stage B N3 교훈): root/
    cluster 타입, 멤버 완전성, root 유일, 순환, bg_prompt 규칙까지.
    """
    if not isinstance(plan, dict):
        return ["plan 이 객체 아님"]
    clusters = plan.get("clusters")
    if not isinstance(clusters, list) or not clusters:
        return ["clusters 가 비어 있거나 배열 아님"]

    violations: List[str] = []
    by_id: Dict[str, Dict[str, Any]] = {}
    seen_members: List[str] = []
    roots: List[str] = []
    for i, c in enumerate(clusters):
        if not isinstance(c, dict):
            violations.append(f"[{i}] cluster 가 객체 아님")
            continue
        cid = c.get("cluster_id") or ""
        if cid not in _ALLOWED_CLUSTER_IDS:
            violations.append(f"[{i}] cluster_id {cid!r} 허용 밖")
        elif cid in by_id:
            violations.append(f"cluster_id {cid} 중복")
        else:
            by_id[cid] = c
        members = c.get("member_shot_keys")
        if not isinstance(members, list) or not members:
            violations.append(f"{cid or i} member_shot_keys 비어 있음")
            members = []
        for m in members:
            if m in seen_members:
                violations.append(f"샷 {m} 가 복수 cluster 에 중복 소속")
            seen_members.append(m)
            if m not in expected_shot_keys:
                violations.append(f"대상 밖 샷 {m}")
        feats = c.get("stable_visible_features_en")
        if not isinstance(feats, list) or not feats:
            violations.append(f"{cid or i} stable_visible_features 비어 있음")
        parent = c.get("parent_cluster_id")
        if parent is None:
            roots.append(cid)
        bg = (c.get("bg_prompt_en") or "").strip()
        if parent is None and bg:
            violations.append(
                f"root {cid} 에 bg_prompt_en 금지 — root 배경=확정 플레이트")
        if parent is not None and not bg:
            violations.append(f"{cid} bg_prompt_en 누락 (비-root 필수)")

    missing = [k for k in expected_shot_keys if k not in seen_members]
    if missing:
        violations.append(f"멤버 누락 샷: {missing}")
    if len(roots) != 1:
        violations.append(f"root(parent null) 는 정확히 1개여야 함: {roots}")

    # parent 유효·자기참조·순환
    for cid, c in by_id.items():
        parent = c.get("parent_cluster_id")
        if parent is None:
            continue
        if parent == cid:
            violations.append(f"{cid} 자기 참조 parent")
            continue
        if parent not in by_id:
            violations.append(f"{cid} 의 parent {parent!r} 미정의")
            continue
        hops, cur = 0, parent
        while cur is not None and hops <= len(by_id):
            cur = (by_id.get(cur) or {}).get("parent_cluster_id")
            hops += 1
        if hops > len(by_id):
            violations.append(f"{cid} 에서 parent 순환 감지")
    return violations


def generation_order(plan: Dict[str, Any]) -> List[str]:
    """DAG topo 순서 — root 먼저, parent 가 항상 앞, 동레벨=id 정렬."""
    clusters = plan.get("clusters") or []
    by_id = {c["cluster_id"]: c for c in clusters}
    order: List[str] = []
    remaining = sorted(by_id)
    while remaining:
        progressed = False
        for cid in list(remaining):
            parent = by_id[cid].get("parent_cluster_id")
            if parent is None or parent in order:
                order.append(cid)
                remaining.remove(cid)
                progressed = True
        if not progressed:  # 순환 — validator 가 사전 차단하지만 방어
            raise ValueError(f"cluster DAG 순환: {remaining}")
    return order
```

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

Run: `cd /Users/manta/Documents/Projects/TheRoad-I1/backend && .venv/bin/pytest tests/pipeline/test_outdoor_view_cluster.py -v`
Expected: 8 passed

- [ ] **Step 5: Commit**

```bash
git add backend/app/modules/pipeline/outdoor_view_cluster.py backend/tests/pipeline/test_outdoor_view_cluster.py
git commit -m "feat(lane2): view cluster 스키마·DAG 검증·결정론 topo (Stage C1)"
```

### Task C2: cluster plan 프롬프트 팩 + LLM 러너

**Files:**
- Create: `prompts/_base/outdoor_view_cluster/1.202607150600/system.md`
- Create: `prompts/_base/outdoor_view_cluster/1.202607150600/user_template.md`
- Modify: `backend/app/modules/pipeline/outdoor_view_cluster.py` (러너 append)
- Test: `backend/tests/pipeline/test_outdoor_view_cluster.py` (러너 테스트 append)

**Interfaces:**
- Consumes: C1 전부; `outdoor_shot_grounding.build_legend_block/build_shot_block`, `multiroll_gemini.png_part`, `prompt_loader.load_prompt`, `llm_client.call_structured`
- Produces: `run_view_cluster_plan_group(*, spec, structure_shots, scene_texts, plate_images, prompt_version="1", call_structured_fn=None, project_config=None, opik_metadata=None, max_attempts=3) -> {"plan": dict, "attempts": int}` — `structure_shots`=[{scene_index, shot_index, description, characters?, camera_direction?}], `scene_texts`=Dict[int,str] 전문, `plate_images`=[(label, bytes)] 기존 확정 플레이트(참고 첨부), 소진 시 `AppError(step.contract_violation.outdoor_view_cluster)`

- [ ] **Step 1: system.md 작성** (범용 계약 — 작품 고유명 0)

```
You are a film pre-production spatial planner. You receive: a structured
outdoor place spec, confirmed photographic plates of the fixed structure
(attached — the established look), the structure-dominant shots bound to
this place, and the full scene texts.

Partition the shots into SPATIAL VIEW CLUSTERS and output a seed DAG:

1. A cluster groups shots that share overlapping faces, openings,
   levels or approach axes of the SAME fixed structure. Cluster ONLY by
   what is physically visible and overlapping — NEVER by shot numbers,
   characters present, story beats or time of day.
2. For each cluster author: spatial_scope_en (what part of the
   structure this view family covers), stable_visible_features_en
   (fixed features every shot of this cluster must keep consistent),
   view_axis_family_en (the shared viewing axis), overlap_evidence_en
   (why these shots overlap spatially).
3. parent_cluster_id builds the seed DAG: exactly ONE root cluster
   (parent = null) — the view family best anchored by the attached
   confirmed plates. A child's parent must be the cluster it shares the
   most visible structure with.
4. bg_prompt_en: for every NON-root cluster, a single photographic
   background prompt describing that cluster's view of the SAME
   structure (empty scene, no people). Reuse the structure's materials,
   colors and openings — the confirmed plate is the look authority.
   The ROOT cluster's bg_prompt_en must be an empty string (its
   background IS the confirmed plate).
5. rationale_ko: 한국어 근거.

Every listed shot belongs to exactly one cluster. Output strictly in
the given JSON schema. Never use character/place proper names in any
English field.
```

- [ ] **Step 2: user_template.md 작성**

```
PLACE SPEC (mapped elements of the property):
{legend_block}

ZONES:
{zones_block}

STRUCTURE-DOMINANT SHOTS (cluster every one exactly once):
{shots_block}

FULL SCENE TEXTS (authoritative context):
{scene_texts_block}
```

- [ ] **Step 3: 러너 테스트 append** (test_outdoor_view_cluster.py)

```python
def _spec():
    return {
        "zone_labels_en": ["Structure Front"],
        "items": [{"code": "P1", "kind": "door",
                   "name_en": "steel entry door",
                   "placement_en": "center of the facade"}],
    }


def _shots():
    return [
        {"scene_index": 3, "shot_index": 1,
         "description": "현관 앞에 선 인물"},
        {"scene_index": 3, "shot_index": 2,
         "description": "골목에서 본 건물 측면"},
        {"scene_index": 5, "shot_index": 1,
         "description": "지붕 위 전경"},
    ]


def _run_kwargs(fake_fn):
    return dict(
        spec=_spec(),
        structure_shots=_shots(),
        scene_texts={3: "그가 현관 앞에 선다.", 5: "지붕 위로 올라간다."},
        plate_images=[("CONFIRMED PLATE — front", b"\x89PNG-fake")],
        call_structured_fn=fake_fn,
    )


def _llm_plan():
    p = _plan()
    p["clusters"][0]["member_shot_keys"] = ["S3_Shot1", "S3_Shot2"]
    p["clusters"][1]["member_shot_keys"] = ["S5_Shot1"]
    return p


def test_run_ok_returns_plan_with_order():
    captured = {}

    def fake(step, system, user, schema, **kw):
        captured["user"] = user
        return _llm_plan()

    from app.modules.pipeline.outdoor_view_cluster import (
        run_view_cluster_plan_group,
    )
    out = run_view_cluster_plan_group(**_run_kwargs(fake))
    assert out["attempts"] == 1
    assert [c["cluster_id"] for c in out["plan"]["clusters"]] == [
        "CL1", "CL2"]
    texts = [p.get("text", "") for p in captured["user"]
             if p.get("type") == "text"]
    joined = "\n".join(texts)
    assert "그가 현관 앞에 선다." in joined          # 씬 전문
    assert "현관 앞에 선 인물" in joined              # 샷 블록
    assert any(p.get("type") == "image_url" for p in captured["user"])


def test_run_retries_on_violation_then_ok():
    bad = _llm_plan()
    bad["clusters"][1]["member_shot_keys"] = []  # S5_Shot1 누락
    responses = [bad, _llm_plan()]
    calls = []

    def fake(step, system, user, schema, **kw):
        calls.append(user)
        return responses[len(calls) - 1]

    from app.modules.pipeline.outdoor_view_cluster import (
        run_view_cluster_plan_group,
    )
    out = run_view_cluster_plan_group(**_run_kwargs(fake))
    assert out["attempts"] == 2
    assert "재시도" in calls[1][-1]["text"]


def test_run_exhausts_raises_app_error():
    bad = _llm_plan()
    bad["clusters"][0]["parent_cluster_id"] = "CL2"  # root 0

    def fake(step, system, user, schema, **kw):
        return bad

    from app.core.errors import AppError
    from app.modules.pipeline.outdoor_view_cluster import (
        run_view_cluster_plan_group,
    )
    with pytest.raises(AppError) as ei:
        run_view_cluster_plan_group(**_run_kwargs(fake), max_attempts=2)
    assert ei.value.code == "step.contract_violation.outdoor_view_cluster"
```

- [ ] **Step 4: 실패 확인** — `cd /Users/manta/Documents/Projects/TheRoad-I1/backend && .venv/bin/pytest tests/pipeline/test_outdoor_view_cluster.py -v` → 신규 3건 FAIL

- [ ] **Step 5: 러너 구현** (outdoor_view_cluster.py에 append)

```python
def _shot_key(sh: Dict[str, Any]) -> str:
    return f"S{sh['scene_index']}_Shot{sh['shot_index']}"


def run_view_cluster_plan_group(
    *,
    spec: Dict[str, Any],
    structure_shots: List[Dict[str, Any]],
    scene_texts: Dict[int, str],
    plate_images: List[tuple],
    prompt_version: str = "1",
    call_structured_fn=None,
    project_config: Dict[str, Any] | None = None,
    opik_metadata: Dict[str, Any] | None = None,
    max_attempts: int = 3,
) -> Dict[str, Any]:
    """그룹 1개 view cluster plan — 위반 힌트 재시도.

    반환 {"plan": <검증 통과 plan>, "attempts": n}.
    소진 시 AppError(step.contract_violation.outdoor_view_cluster).
    """
    from app.core.errors import AppError

    if call_structured_fn is None:
        from app.modules.llm.llm_client import call_structured

        call_structured_fn = call_structured

    from app.modules.pipeline.multiroll_gemini import png_part
    from app.modules.pipeline.outdoor_shot_grounding import (
        build_legend_block,
        build_shot_block,
    )
    from app.modules.prompt_loader import load_prompt

    resolved = resolve_prompt_version(prompt_version)
    system = load_prompt(_MODULE, "system", version=resolved)
    template = load_prompt(_MODULE, "user_template", version=resolved)
    shot_keys = [_shot_key(sh) for sh in structure_shots]
    schema = build_cluster_schema(shot_keys)

    filled = template
    for key, val in {
        "legend_block": build_legend_block(spec),
        "zones_block": "\n".join(
            f"- {z}" for z in spec.get("zone_labels_en", []) or []
        ),
        "shots_block": "\n\n".join(
            build_shot_block(sh) for sh in structure_shots
        ),
        # 씬 원문 전문 — 절대 자르지 않는다 (CLAUDE.md 절대 규칙)
        "scene_texts_block": "\n\n".join(
            f"[Scene {si}]\n{scene_texts[si]}" for si in sorted(scene_texts)
        ),
    }.items():
        filled = filled.replace("{" + key + "}", val)
    base_parts: List[Dict[str, Any]] = []
    for label, png in plate_images:
        base_parts.append({
            "type": "text",
            "text": f"CONFIRMED STRUCTURE PLATE — {label}:",
        })
        base_parts.append(png_part(png))
    base_parts.append({"type": "text", "text": filled})

    attempts = 0
    parts = base_parts
    last: List[str] = []
    while attempts < max_attempts:
        attempts += 1
        result = call_structured_fn(
            _MODULE, system, parts, schema,
            project_config=project_config,
            schema_name=_MODULE,
            opik_metadata=opik_metadata,
        )
        violations = validate_cluster_plan(result or {}, shot_keys)
        if not violations:
            return {"plan": result, "attempts": attempts}
        last = violations
        hint = "\n".join(
            ["", "", "[재시도 — 직전 응답이 아래 계약을 위반했습니다. 전부",
             " 고쳐서 전체 결과를 다시 출력하세요:]"]
            + [f"  - {v}" for v in violations]
        )
        parts = base_parts + [{"type": "text", "text": hint}]

    raise AppError(
        code="step.contract_violation.outdoor_view_cluster",
        message=f"view cluster 계약 위반 (attempts={max_attempts}): "
                + "; ".join(last[:8]),
        status_code=422,
    )
```

- [ ] **Step 6: 통과 확인** — `cd /Users/manta/Documents/Projects/TheRoad-I1/backend && .venv/bin/pytest tests/pipeline/test_outdoor_view_cluster.py -v` → 11 passed

- [ ] **Step 7: Commit**

```bash
git add prompts/_base/outdoor_view_cluster backend/app/modules/pipeline/outdoor_view_cluster.py backend/tests/pipeline/test_outdoor_view_cluster.py
git commit -m "feat(lane2): view cluster plan 팩 v1 + fail-closed LLM 러너 (Stage C2)"
```

### Task C3: cluster 배경 생성 계약 — 팩 + 프롬프트·참조 조립

**Files:**
- Create: `prompts/_base/cluster_bg/1.202607150610/seed_clause.md`
- Create: `prompts/_base/cluster_bg/1.202607150610/parent_clause.md`
- Create: `prompts/_base/cluster_bg/1.202607150610/no_people.md`
- Modify: `backend/app/modules/pipeline/outdoor_view_cluster.py` (조립 append)
- Test: `backend/tests/pipeline/test_outdoor_view_cluster.py` (조립 테스트 append)

**Interfaces:**
- Consumes: C1 cluster shape
- Produces:
  - `BG_PACK_MODULE = "cluster_bg"`, `resolve_bg_pack_version(version) -> str`
  - `build_cluster_bg_prompt(*, cluster: dict, place_text: str, world_anchor: str, prompt_version: str = "1") -> str` — bg_prompt_en+seed/parent 계약+no_people 조립. root cluster 전달 시 ValueError(root 배경=플레이트)
  - `build_cluster_bg_refs(*, root_plate: Path|bytes, parent_bg: Path|bytes|None, root_label: str, parent_label: str) -> list[tuple]` — [(라벨, 소스)] 결정론(맵·콘티 절대 미포함이 구조상 보장)

- [ ] **Step 1: 프롬프트 팩 작성**

`seed_clause.md`:
```
SEED — CONFIRMED STRUCTURE PLATE (attached): this is the established
look of the SAME fixed structure. Keep its materials, colors, openings,
levels and wear identical — you are photographing the same real place
from this cluster's view axis, not inventing a new building.
```

`parent_clause.md`:
```
ADJACENT VIEW (attached): a confirmed background of an overlapping view
family of the same structure. Where the two views share faces or
openings, keep them consistent with this image.
```

`no_people.md`:
```
EMPTY SCENE: no people, no animals, no vehicles in motion. A clean
photographic background plate only.
```

- [ ] **Step 2: 조립 테스트 append** (test_outdoor_view_cluster.py)

```python
def test_bg_prompt_assembles_contracts_and_rejects_root():
    from app.modules.pipeline.outdoor_view_cluster import (
        build_cluster_bg_prompt,
    )
    cl = _cluster("CL2", ["S5_Shot1"], "CL1",
                  "the same structure seen from the side alley")
    p = build_cluster_bg_prompt(
        cluster=cl, place_text="EXT. SAMPLE 주택 - 낮",
        world_anchor=" — contemporary SAMPLE region, 2026",
    )
    assert "the same structure seen from the side alley" in p
    assert "EXT. SAMPLE 주택 - 낮" in p
    assert "contemporary SAMPLE region" in p
    assert "CONFIRMED STRUCTURE PLATE" in p    # seed_clause
    assert "no people" in p                    # no_people
    assert "brick facade" in p                 # stable features 주입
    root = _cluster("CL1", ["S3_Shot1"], None, "")
    with pytest.raises(ValueError):
        build_cluster_bg_prompt(
            cluster=root, place_text="x", world_anchor="")


def test_bg_refs_order_and_optional_parent():
    from pathlib import Path

    from app.modules.pipeline.outdoor_view_cluster import (
        build_cluster_bg_refs,
    )
    refs = build_cluster_bg_refs(
        root_plate=Path("/tmp/SAMPLE_root.png"), parent_bg=None,
        root_label="CONFIRMED PLATE", parent_label="ADJACENT VIEW",
    )
    assert [r[0] for r in refs] == ["CONFIRMED PLATE"]
    refs2 = build_cluster_bg_refs(
        root_plate=b"png", parent_bg=b"png2",
        root_label="CONFIRMED PLATE", parent_label="ADJACENT VIEW",
    )
    assert [r[0] for r in refs2] == ["CONFIRMED PLATE", "ADJACENT VIEW"]
```

- [ ] **Step 3: 실패 확인** — `cd /Users/manta/Documents/Projects/TheRoad-I1/backend && .venv/bin/pytest tests/pipeline/test_outdoor_view_cluster.py -v` → 신규 2건 FAIL

- [ ] **Step 4: 구현** (outdoor_view_cluster.py에 append)

```python
BG_PACK_MODULE = "cluster_bg"

BG_PACK_VERSION_MAP = {
    "1": "1.202607150610",
}


def resolve_bg_pack_version(version: str) -> str:
    if version not in BG_PACK_VERSION_MAP:
        raise ValueError(f"cluster_bg 팩 버전 없음: {version}")
    return BG_PACK_VERSION_MAP[version]


def build_cluster_bg_prompt(
    *,
    cluster: Dict[str, Any],
    place_text: str,
    world_anchor: str,
    prompt_version: str = "1",
) -> str:
    """비-root cluster 배경 프롬프트 조립 (head → seed → parent 계약 →
    LOCATION → features → no_people). root 는 배경=확정 플레이트이므로
    ValueError."""
    from app.modules.prompt_loader import load_prompt

    if cluster.get("parent_cluster_id") is None:
        raise ValueError("root cluster 배경은 확정 플레이트 — 생성 금지")
    bg = (cluster.get("bg_prompt_en") or "").strip()
    if not bg:
        raise ValueError(f"{cluster.get('cluster_id')} bg_prompt_en 없음")

    resolved = resolve_bg_pack_version(prompt_version)
    feats = ", ".join(cluster.get("stable_visible_features_en") or [])
    parts = [
        f"A photographic background plate{world_anchor}. {bg}",
        load_prompt(BG_PACK_MODULE, "seed_clause", version=resolved).strip(),
        load_prompt(BG_PACK_MODULE, "parent_clause",
                    version=resolved).strip(),
        f"THE LOCATION: {place_text}",
        f"KEEP CONSISTENT (fixed features of this view family): {feats}",
        load_prompt(BG_PACK_MODULE, "no_people", version=resolved).strip(),
    ]
    return "\n\n".join(parts)


def build_cluster_bg_refs(
    *,
    root_plate: Any,
    parent_bg: Any = None,
    root_label: str,
    parent_label: str,
) -> List[tuple]:
    """cluster 배경 생성 라벨드 참조 — [root 씨드(+부모 배경)].

    맵/콘티/photo canon 은 구조상 진입 불가 (레인2 acceptance).
    """
    refs: List[tuple] = [(root_label, root_plate)]
    if parent_bg is not None:
        refs.append((parent_label, parent_bg))
    return refs
```

주의: parent가 root인 cluster는 parent_bg를 별도 첨부하지 않는다(root plate가 이미 씨드) — canary 러너 규칙으로 명시. parent_clause는 프롬프트에 항상 실리지만 ADJACENT VIEW 미첨부 시 무해(참조 라벨 부재).

- [ ] **Step 5: 통과 확인** — `cd /Users/manta/Documents/Projects/TheRoad-I1/backend && .venv/bin/pytest tests/pipeline/test_outdoor_view_cluster.py -v` → 13 passed. 회귀: `.venv/bin/pytest tests/unit -q` → 1027 passed

- [ ] **Step 6: Commit**

```bash
git add prompts/_base/cluster_bg backend/app/modules/pipeline/outdoor_view_cluster.py backend/tests/pipeline/test_outdoor_view_cluster.py
git commit -m "feat(lane2): cluster 배경 생성 계약 — cluster_bg 팩 v1 + 프롬프트·참조 조립 (Stage C3)"
```

### Task C4: rooftop 그룹 canary + 갤러리 + Codex 리뷰

**Files:**
- Create: `<scratchpad>/run_lane2_canary.py` (커밋 금지)
- Create: `<scratchpad>/build_lane2_gallery.py` (커밋 금지)

**Interfaces:**
- Consumes: C1~C3 전부; Stage B canary 러너(`run_lane1_canary.py`)의 DB/ref 로딩·multiroll 배선·still 조립 블록 복제; `still_recipe.build_still_prompt`(prev_used/prev_usage_en=background-only 문구)
- Produces: `lane2_canary/` 산출(cluster_plan.json/cluster 배경/스틸 롤·sel) + 갤러리 HTML(8899, LAN IP)

- [ ] **Step 1: canary 러너 작성** — 대상=bg_rooftop_housing_complex(structure_plate 6샷). 흐름:
  1. lane plan CP에서 structure_plate 샷 추출 + shot_validator description/characters 병합 + shot_staging camera_direction 병합(Stage B 교훈)
  2. `run_view_cluster_plan_group`(모델 gpt 명시, plate_images=멤버 샷들의 `resolve_shot_plate_map` 확정 플레이트 중복 제거 전부 첨부) → `cluster_plan.json` 저장 + `generation_order` 기록
  3. root cluster 배경=멤버 샷 plate_map 최빈 플레이트(복사, 생성 0). 비-root cluster: `build_cluster_bg_prompt`+`build_cluster_bg_refs`(root 씨드, parent가 비-root면 그 확정 배경 추가) → `run_multiroll_select`(nb2 3롤+gemini 판정+critique — plate 계열 judge 텍스트 재사용)
  4. 스틸 canary 3샷(서로 다른 cluster 우선 + 같은 cluster 내 prev 체인 1쌍 포함되게 스토리 순서 선정): `build_still_prompt`(pose/carried/traits/world_anchor/time — Stage B 러너 블록 재사용, **콘티·맵 참조 0**), refs=[해당 cluster 배경+char refs(+같은 cluster 직전 완료 still=`PREVIOUS SHOT STILL — fixed background terrain/tone ONLY` + background-only usage 절)]
  5. manifest(각 단계 기록)+재개(수동 재실행 대비 cluster_plan.json/배경 존재 시 reuse)
- [ ] **Step 2: 실행** — 반드시 `cd /Users/manta/Documents/Projects/TheRoad-I1/backend && .venv/bin/python <scratchpad>/run_lane2_canary.py` (cwd 교훈). 실패 시 원인 조사→프롬프트 미세조정(신규 버전 디렉토리) 반복
- [ ] **Step 3: 갤러리** — 섹션: ①cluster plan 표(멤버/scope/features/axis/overlap evidence/rationale, DAG 시각화=순서 목록) ②cluster 배경 체인(root 플레이트→각 cluster A/B/C→선정→fix) ③스틸 3샷(참조 명시) ④acceptance 자가 점검(맵·콘티 ref 0/root→cluster→still 체인/prev=background-only). 8899 docroot, LAN IP 제시
- [ ] **Step 4: Codex diff 리뷰 요청**(`[claude로부터 - 요청]`, C1~C3 커밋 범위) → 지적 반영 커밋 → 사용자에게 갤러리 URL 공유 + Stage D 착수 합의 대기

## Self-Review 결과

- 스펙 커버리지: 합의 결정 4의 산출 필드 전부 스키마에 수록(generation_order만 코드 유도로 대체 — '설계 확정 사항'에 명시), root plate 씨드/DAG 순서/겹치는 cluster 추가 씨드=C3·C4, prev=background-only role=C4, 레인2 acceptance 3항=C4 갤러리 자가 점검. plate_map 호환 export(downstream still_recipe 최소 변경)는 Stage D 배선 범위로 이관 — 로드맵 문구("still_recipe 변경 최소화")와 일치.
- 플레이스홀더: C4는 scratchpad 러너 관례(Stage B와 동일)로 흐름+정확한 시그니처·라벨 특정. C1~C3 코드 전문 수록.
- 타입 일관성: `_cluster()` fixture shape=C1 스키마=C2 러너 반환=C3 조립 입력 동일. `resolve_prompt_version`(plan 팩)/`resolve_bg_pack_version`(배경 팩) 분리. `run_view_cluster_plan_group`의 `plate_images=[(label, bytes)]`가 C4 소비와 일치.
