# 야외 스틸 3레인 재설계 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:** 야외 스틸 생성을 사용자 확정 설계 v2의 3레인(개활지=마커 맵, 복잡 구조물=씨드 클러스터 배경, 실내=기존)으로 재설계한다. 기존 outdoor_map_conti의 "complex→canon 실사 맵 플레이트" 의미 경로는 폐기(dormant)한다.

**Architecture:** ① 신규 `outdoor_lane_plan` LLM 스텝이 야외 장소를 place_segment(subspace)로 분할하고 각 선택 샷을 세그먼트+레인(`map_marker`/`structure_plate`)에 바인딩한다(evidence-bound, 저신뢰=보수적으로 structure_plate). ② 레인1: LLM은 normalized 좌표 geometry JSON만 저작 → PIL 결정론 렌더러가 마커 맵(control PNG)을 합성 → i2i로 marker-free 스케치 → 스틸(스케치+prev selected still+엔티티, photo canon 참조 0). ③ 레인2: root 플레이트(plate_multiroll 3롤+VLM 선정+수정)를 씨드로 LLM이 저작한 spatial view cluster DAG 순서로 군집별 배경 생성 → 스틸(콘티 무사용, prev는 구조 겹침 판정 시 previous_background_only role). ④ 실내 경로는 byte-identical.

**Tech Stack:** FastAPI backend, PostgreSQL, checkpoint manifest(StepRunner), litellm(call_structured), nb2/gemini 이미지 생성, PIL(마커 렌더), pytest(결정론만 — LLM/VLM 완성도는 E2E 육안).

## Global Constraints

- **LLM 전달 데이터 절대 자르지 않음** (씬 원문 전문, `[:N]` 금지 — CLAUDE.md 절대 규칙)
- **시나리오 의존 코딩 금지**: rule/prompt/code에 작품 고유명 0, fixture=`SAMPLE_*` (memory ★★★)
- **글자/substring 의미 판단 금지**: 의미 추출=LLM evidence-backed structured only (memory ★★★)
- **TDD는 deterministic 영역만**: 스키마/렌더러/라우팅/검증 로직만 유닛, LLM·이미지 완성도=canary 육안 (memory ★★★★)
- **기존 모듈 삭제 금지 / 프롬프트 파일 덮어쓰기 금지**(새 버전 디렉토리), 버전 형식 `1.YYYYMMDDHHmm`
- **플래그 default OFF**, ON 시에만 config_hash 스탬프 (기존 still_recipe 관례)
- 모든 lane decision/control spec/cluster plan은 config_hash와 input_fingerprint에 포함 (Codex acceptance)
- 실내(fp) 경로는 분기 이전부터 끝까지 byte-identical (Codex acceptance)

## 합의된 설계 결정 (2026-07-14, 사용자 확정 v2 + Codex 합의)

1. **게이트**: 장소 1비트 아님 — 샷의 공간 일관성을 지배하는 것이 이동/방향 위상(`map_marker`)인지 고정 구조물의 면·개구부·층·가림 관계(`structure_plate`)인지를 LLM이 place_segment 단위로 판정. 저신뢰/혼합=structure_plate 보수 라우팅+audit evidence.
2. **마커 주입=프로그래매틱 합성**: 맵 PNG는 immutable SOT. LLM은 normalized(0..1) geometry JSON만(entity slot E1/E2…, camera origin, look target/direction vector). PIL 렌더러가 shot_map_control.png 결정론 합성(엔티티 원, 카메라 쐐기, 방향선). validator fail-closed(bounds/vector/required slots/cone 방향). base map·control spec JSON·control PNG 각각 저장. 이미지 안에 고유명·장소명 0.
3. **레인1 룩 소스**: 첫 샷=텍스트 SOT(location spec+visual world era/region+씬 본문)+스케치+엔티티 passport만. 첫 선정 still을 해당 zone의 visual anchor로 승격, 후속 샷은 같은 세그먼트에서 실제 겹침 있는 가장 가까운 앞쪽 selected still을 background continuity로 추가. 없으면 no-prev가 정상. 자동 생성 photo seed 금지.
4. **레인2 군집=spatial view cluster**: 기준=같은 고정 구조의 겹치는 면/개구부/레벨/접근축 공유(샷 번호·인물·시간 유사도 금지). 산출=cluster_id, member_shot_keys, spatial_scope, stable_visible_features, view_axis_family, overlap_evidence, root_or_parent_cluster, generation_order. root plate 1장(3롤+VLM+수정)→cluster 배경은 root 씨드+DAG 순서, 겹치는 cluster만 직전 확정 cluster 배경 추가 씨드. downstream은 기존 plate_map 호환 export(still_recipe 변경 최소화). prev=`previous_background_only` 별도 role("고정 배경만, 인물·포즈·구도·시간·조명 복사 금지, 현재 본문이 시간/조명 SOT").
5. **폐기/재활용 경계**:
   - 폐기(dormant, 새 기본 경로에서 호출 0 보장, 코드 삭제 금지): `judge_complex_shots`의 complex→map 의미, `build_map_bg_prompt`+canon master+map→photoreal map_plate 생성, `run_outdoor_map_conti` 결과의 plate_map 덮어쓰기 결합.
   - 재사용: outdoor_place_spec 구조화 spec, 사이트 맵 PNG, outdoor_shot_grounding의 입력·재시도 뼈대(단 geometry schema 신버전), shot_conti_light의 sidecar fingerprint/stale archive/lineage helper(프롬프트는 control_kind=marker_map 전용 새 pack), plate_multiroll/multiroll_select/ImageAsset annotate/still_recipe 순차 생성.
6. **구현 순서**: A(lane plan, 이미지 생성 0)→B(레인1 수직 canary)→C(레인2 cluster DAG)→D(공통 계약 병합+mixed E2E)→E(flag 전환, 기존 자산/CP 삭제·자동 마이그레이션 없음).

## Acceptance (전 스테이지 공통, Codex 합의 + 보완)

- lane 판정에 substring/구조물 개수 휴리스틱 0 — evidence-bound LLM only
- 경계 실증 3샷: S11sh5(옥탑)=structure_plate 기대, S15sh6(정류장)=map_marker 기대, S26sh5(항구)=혼합 경계 — evidence 검토로 판단 (audit HTML에서 육안 확인, 코드/테스트에는 미하드코딩)
- 레인1: photo canon ref 0, marker geometry 구조 저장, 스케치 marker leakage 0(VLM gate), 후속 prev edge 존재
- 레인2: map/콘티 ref 0, root→cluster→still lineage, prev는 background-only role
- 레인3: 기존 FP 산출·hash·ref 순서 byte-identical

## Stage 로드맵 (B~E는 착수 시 별도 플랜으로 상세화 — 각각 독립 테스트 가능 산출)

- **Stage A (이 플랜의 상세 태스크)**: `outdoor_lane_plan` 스텝 — 이미지 생성 없이 세그먼트/레인/바인딩 저작 + 58샷 audit HTML 육안 검토로 분류 acceptance 먼저 잠금.
- **Stage B**: 레인1 수직 — geometry grounding 신스키마 → PIL marker overlay+validator → marker_map 전용 스케치 pack → still(no-prev→prev) 2~3샷 canary.
- **Stage C**: 레인2 — view_cluster_plan → root plate multiroll → cluster 배경 DAG → still(no conti, background-only prev) canary.
- **Stage D**: 두 레인 산출을 공통 shot_plate/control 계약으로 still_recipe 병합, 실내 byte-identical+mixed E2E.
- **Stage E**: 기존 outdoor_map_conti_enabled=deprecated·default OFF 유지, 신규 플래그 전환.

---

# Stage A 상세: outdoor_lane_plan

**신규 플래그**: `outdoor_lane_plan_enabled` (settings, default `False`)

### Task A1: 스키마·검증 모듈 (결정론)

**Files:**
- Create: `backend/app/modules/pipeline/outdoor_lane_plan.py`
- Test: `backend/tests/pipeline/test_outdoor_lane_plan.py`

**Interfaces:**
- Produces: `build_lane_schema(spec) -> dict` (segments+shot_bindings JSON Schema, zone enum lock), `validate_lane_plan(result, group_shots) -> list[str]` (위반 리스트), `apply_conservative_routing(result) -> dict` (저신뢰/혼합→structure_plate, `routed_conservatively: true` 마킹), `LANES = ("map_marker", "structure_plate")`

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

```python
"""outdoor_lane_plan 결정론 테스트 — 스키마/검증/보수 라우팅만.

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

from app.modules.pipeline.outdoor_lane_plan import (
    LANES,
    apply_conservative_routing,
    build_lane_schema,
    validate_lane_plan,
)


def _spec():
    return {
        "zone_labels_en": ["Open Field", "Structure Front"],
        "items": [
            {"code": "P1", "kind": "gate", "name_en": "front entry gate"},
        ],
    }


def _shots():
    return [
        {"scene_index": 3, "shot_index": 1},
        {"scene_index": 3, "shot_index": 2},
    ]


def _plan(**over):
    base = {
        "segments": [
            {"segment_id": "seg1", "label_en": "open approach path",
             "dominant_mode": "movement",
             "evidence": [{"scene_index": 3, "quote_ko": "달려간다"}],
             "confidence": "high"},
        ],
        "shot_bindings": [
            {"scene_index": 3, "shot_index": 1, "segment_id": "seg1",
             "lane": "map_marker", "confidence": "high",
             "rationale_ko": "이동 위상이 지배",
             "evidence": {"scene_index": 3, "quote_ko": "달려간다"}},
            {"scene_index": 3, "shot_index": 2, "segment_id": "seg1",
             "lane": "map_marker", "confidence": "high",
             "rationale_ko": "동일 세그먼트 연속 이동",
             "evidence": {"scene_index": 3, "quote_ko": "멀어진다"}},
        ],
    }
    base.update(over)
    return base


def test_schema_locks_lane_and_mode_enums():
    schema = build_lane_schema(_spec())
    b = schema["properties"]["shot_bindings"]["items"]["properties"]
    assert b["lane"]["enum"] == list(LANES)
    s = schema["properties"]["segments"]["items"]["properties"]
    assert s["dominant_mode"]["enum"] == ["movement", "structure"]
    assert s["confidence"]["enum"] == ["high", "medium", "low"]


def test_validate_passes_clean():
    assert validate_lane_plan(_plan(), _shots()) == []


def test_validate_rejects_unbound_or_duplicate_shot():
    p = _plan()
    p["shot_bindings"] = p["shot_bindings"][:1]  # (3,2) 누락
    assert any("바인딩 누락" in v for v in validate_lane_plan(p, _shots()))
    p2 = _plan()
    p2["shot_bindings"].append(dict(p2["shot_bindings"][0]))  # (3,1) 중복
    assert any("중복" in v for v in validate_lane_plan(p2, _shots()))


def test_validate_rejects_binding_to_unknown_segment():
    p = _plan()
    p["shot_bindings"][0]["segment_id"] = "seg9"
    assert any("segment" in v for v in validate_lane_plan(p, _shots()))


def test_validate_requires_evidence():
    p = _plan()
    p["shot_bindings"][0]["evidence"] = None
    assert any("evidence" in v for v in validate_lane_plan(p, _shots()))


def test_conservative_routing_low_confidence_and_mixed():
    p = _plan()
    p["shot_bindings"][0]["confidence"] = "low"
    out = apply_conservative_routing(p)
    b0 = out["shot_bindings"][0]
    assert b0["lane"] == "structure_plate"
    assert b0["routed_conservatively"] is True
    # 세그먼트 dominant_mode=structure 인데 lane=map_marker → 혼합 → 보수
    p2 = _plan()
    p2["segments"][0]["dominant_mode"] = "structure"
    out2 = apply_conservative_routing(p2)
    assert all(b["lane"] == "structure_plate" for b in out2["shot_bindings"])


def test_conservative_routing_high_confidence_untouched():
    out = apply_conservative_routing(_plan())
    assert all(b["lane"] == "map_marker" for b in out["shot_bindings"])
    assert all(not b.get("routed_conservatively") for b in out["shot_bindings"])
```

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

Run: `cd backend && .venv/bin/pytest tests/pipeline/test_outdoor_lane_plan.py -v`
Expected: FAIL (`ModuleNotFoundError: app.modules.pipeline.outdoor_lane_plan`)

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

```python
"""야외 3레인 게이트 — place_segment 분할+샷 바인딩+레인 판정 (설계 v2 Stage A).

LLM 은 evidence-bound 구조 JSON 만 저작한다. 코드는 스키마 잠금·완전성
검증·보수 라우팅(저신뢰/혼합→structure_plate)만 담당 — substring/개수
휴리스틱으로 의미를 판단하지 않는다.
"""
from __future__ import annotations

import copy
from typing import Any, Dict, List, Sequence

LANES = ("map_marker", "structure_plate")
_MODES = ("movement", "structure")
_CONF = ("high", "medium", "low")

_MODULE = "outdoor_lane_plan"

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


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


def build_lane_schema(spec: Dict[str, Any]) -> Dict[str, Any]:
    """segments+shot_bindings 스키마 — lane/mode/confidence enum 잠금."""
    evidence = {
        "type": "object",
        "properties": {
            "scene_index": {"type": "integer"},
            "quote_ko": {"type": "string", "minLength": 1},
        },
        "required": ["scene_index", "quote_ko"],
        "additionalProperties": False,
    }
    segment = {
        "type": "object",
        "properties": {
            "segment_id": {"type": "string", "minLength": 1},
            "label_en": {"type": "string", "minLength": 1},
            "dominant_mode": {"enum": list(_MODES)},
            "evidence": {"type": "array", "items": evidence, "minItems": 1},
            "confidence": {"enum": list(_CONF)},
        },
        "required": ["segment_id", "label_en", "dominant_mode",
                     "evidence", "confidence"],
        "additionalProperties": False,
    }
    binding = {
        "type": "object",
        "properties": {
            "scene_index": {"type": "integer"},
            "shot_index": {"type": "integer"},
            "segment_id": {"type": "string", "minLength": 1},
            "lane": {"enum": list(LANES)},
            "confidence": {"enum": list(_CONF)},
            "rationale_ko": {"type": "string", "minLength": 1},
            "evidence": evidence,
        },
        "required": ["scene_index", "shot_index", "segment_id", "lane",
                     "confidence", "rationale_ko", "evidence"],
        "additionalProperties": False,
    }
    return {
        "type": "object",
        "properties": {
            "segments": {"type": "array", "items": segment, "minItems": 1},
            "shot_bindings": {"type": "array", "items": binding,
                              "minItems": 1},
        },
        "required": ["segments", "shot_bindings"],
        "additionalProperties": False,
    }


def validate_lane_plan(
    result: Dict[str, Any], group_shots: Sequence[Dict[str, Any]]
) -> List[str]:
    """결정론 완전성 검증 — 위반 리스트 반환 (fail-closed 재시도용)."""
    violations: List[str] = []
    seg_ids = {s.get("segment_id") for s in result.get("segments") or []}
    expected = {(s["scene_index"], s["shot_index"]) for s in group_shots}
    seen: set = set()
    for b in result.get("shot_bindings") or []:
        key = (b.get("scene_index"), b.get("shot_index"))
        if key in seen:
            violations.append(f"샷 {key} 바인딩 중복")
        seen.add(key)
        if b.get("segment_id") not in seg_ids:
            violations.append(
                f"샷 {key} 가 미정의 segment '{b.get('segment_id')}' 참조")
        if not b.get("evidence") or not (b["evidence"] or {}).get("quote_ko"):
            violations.append(f"샷 {key} evidence(씬 인용) 누락")
    missing = expected - seen
    if missing:
        violations.append(f"바인딩 누락 샷: {sorted(missing)}")
    extra = seen - expected
    if extra:
        violations.append(f"대상 밖 샷 바인딩: {sorted(extra)}")
    return violations


def apply_conservative_routing(result: Dict[str, Any]) -> Dict[str, Any]:
    """저신뢰/혼합 판정 보수 라우팅 — structure_plate 로 강등+감사 마킹.

    강등 조건(둘 중 하나):
    - binding.confidence == "low" 또는 소속 segment.confidence == "low"
    - binding.lane 과 소속 segment.dominant_mode 가 상충
      (movement↔structure_plate 는 보수 방향이라 허용,
       structure↔map_marker 만 혼합으로 본다)
    """
    out = copy.deepcopy(result)
    seg_by_id = {s["segment_id"]: s for s in out.get("segments") or []}
    for b in out.get("shot_bindings") or []:
        seg = seg_by_id.get(b.get("segment_id")) or {}
        low = b.get("confidence") == "low" or seg.get("confidence") == "low"
        mixed = (seg.get("dominant_mode") == "structure"
                 and b.get("lane") == "map_marker")
        if low or mixed:
            b["lane"] = "structure_plate"
            b["routed_conservatively"] = True
            b["routing_reason"] = "low_confidence" if low else "mixed_mode"
    return out
```

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

Run: `cd backend && .venv/bin/pytest tests/pipeline/test_outdoor_lane_plan.py -v`
Expected: 7 passed

- [ ] **Step 5: Commit**

```bash
git add backend/app/modules/pipeline/outdoor_lane_plan.py backend/tests/pipeline/test_outdoor_lane_plan.py
git commit -m "feat(lane): outdoor_lane_plan 스키마·완전성 검증·보수 라우팅 (3레인 Stage A)"
```

### Task A2: 프롬프트 팩 + LLM 러너 함수

**Files:**
- Create: `backend/prompts/_base/outdoor_lane_plan/1.202607142200/system.txt`
- Create: `backend/prompts/_base/outdoor_lane_plan/1.202607142200/user_template.txt`
- Modify: `backend/app/modules/pipeline/outdoor_lane_plan.py` (러너 함수 추가)
- Test: `backend/tests/pipeline/test_outdoor_lane_plan.py` (재시도 루프 테스트 추가)

**Interfaces:**
- Consumes: Task A1의 `build_lane_schema`/`validate_lane_plan`/`apply_conservative_routing`, 기존 `app.modules.prompt_loader.load_prompt`, `app.modules.llm.llm_client.call_structured`
- Produces: `run_outdoor_lane_plan_group(*, spec, group_shots, scene_texts, prompt_version="1", call_structured_fn=None, project_config=None, opik_metadata=None, max_attempts=3) -> {"plan": dict, "attempts": int}` — plan은 보수 라우팅 적용본

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

```
You are a film pre-production spatial planner. You receive: a structured
outdoor place spec (zones and mapped elements), the full scene texts, and
the list of shots bound to this place. Your job:

1. Partition the place into PLACE SEGMENTS (subspaces). A segment is an
   area whose shots share ONE dominant spatial-consistency problem:
   - "movement": what matters is trajectory, distance, direction of
     travel between people/points in open terrain. Small street
     furniture (a bench, a sign, a shelter) may exist and still leave
     movement dominant.
   - "structure": what matters is the faces, openings, levels and
     occlusion relations of a fixed man-made structure (a house, a deck,
     a pier building). Getting these wrong breaks the shot.
2. Bind EVERY listed shot to exactly one segment and decide its lane:
   - lane "map_marker": movement-dominant — the shot will be staged on a
     top-down map with entity/camera markers.
   - lane "structure_plate": structure-dominant — the shot needs a
     photographic background plate of the structure.
3. Every segment and every binding MUST carry evidence: a literal
   Korean quote from the scene text that shows why. Never invent.
4. confidence: "high" only when the scene text clearly supports the
   decision; "medium" when plausible; "low" when you are guessing or the
   shot mixes both problems. Do not inflate confidence.

Output strictly in the given JSON schema. rationale_ko in Korean.
Never use character/place proper names in label_en; describe spaces
generically (e.g. "open approach path", "building front yard").
```

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

```
PLACE SPEC (zones and mapped elements):
{legend_block}

ZONES:
{zones_block}

SHOTS BOUND TO THIS PLACE (bind every one exactly once):
{shots_block}

FULL SCENE TEXTS (evidence source — quote literally):
{scene_texts_block}
```

- [ ] **Step 3: 재시도 루프 테스트 추가** (test_outdoor_lane_plan.py에 append)

```python
def _run_kwargs(fake_fn):
    return dict(
        spec=_spec(),
        group_shots=_shots(),
        scene_texts={3: "그가 달려간다. 멀어진다."},
        call_structured_fn=fake_fn,
    )


def test_run_ok_applies_conservative_routing():
    p = _plan()
    p["shot_bindings"][1]["confidence"] = "low"

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

    from app.modules.pipeline.outdoor_lane_plan import (
        run_outdoor_lane_plan_group,
    )
    out = run_outdoor_lane_plan_group(**_run_kwargs(fake))
    assert out["attempts"] == 1
    assert out["plan"]["shot_bindings"][1]["lane"] == "structure_plate"


def test_run_retries_on_missing_binding_then_ok():
    bad = _plan()
    bad["shot_bindings"] = bad["shot_bindings"][:1]
    responses = [bad, _plan()]
    calls = []

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

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


def test_run_exhausts_raises_app_error():
    bad = _plan()
    bad["shot_bindings"][0]["evidence"] = {"scene_index": 3, "quote_ko": ""}

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

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

- [ ] **Step 4: 실패 확인** — `cd backend && .venv/bin/pytest tests/pipeline/test_outdoor_lane_plan.py -v` → 신규 3건 FAIL (`ImportError: run_outdoor_lane_plan_group`)

- [ ] **Step 5: 러너 구현** (outdoor_lane_plan.py에 append — outdoor_shot_grounding.py:184의 `run_outdoor_shot_grounding_shot` 재시도 뼈대와 동일 패턴)

```python
def _shots_block(group_shots: Sequence[Dict[str, Any]]) -> str:
    lines = []
    for s in group_shots:
        lines.append(
            f"- S{s['scene_index']}_Shot{s['shot_index']}: "
            f"{s.get('description') or ''}"
        )
    return "\n".join(lines)


def _scene_texts_block(scene_texts: Dict[int, str]) -> str:
    parts = []
    for si in sorted(scene_texts):
        # 씬 원문 전문 — 절대 자르지 않는다 (CLAUDE.md 절대 규칙)
        parts.append(f"[Scene {si}]\n{scene_texts[si]}")
    return "\n\n".join(parts)


def run_outdoor_lane_plan_group(
    *,
    spec: Dict[str, Any],
    group_shots: Sequence[Dict[str, Any]],
    scene_texts: Dict[int, str],
    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개 lane plan — 완전성 위반 시 위반 힌트 재시도.

    반환 {"plan": <보수 라우팅 적용본>, "attempts": n}.
    소진 시 AppError(step.contract_violation.outdoor_lane_plan).
    """
    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.outdoor_shot_grounding import (
        build_legend_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)
    schema = build_lane_schema(spec)

    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": _shots_block(group_shots),
        "scene_texts_block": _scene_texts_block(scene_texts),
    }.items():
        filled = filled.replace("{" + key + "}", val)
    base_parts = [{"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_lane_plan(result or {}, group_shots)
        if not violations:
            return {
                "plan": apply_conservative_routing(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_lane_plan",
        message=f"lane plan 계약 위반 (attempts={max_attempts}): "
                + "; ".join(last[:8]),
        status_code=422,
    )
```

- [ ] **Step 6: 통과 확인** — `cd backend && .venv/bin/pytest tests/pipeline/test_outdoor_lane_plan.py -v` → 10 passed

- [ ] **Step 7: Commit**

```bash
git add backend/prompts/_base/outdoor_lane_plan backend/app/modules/pipeline/outdoor_lane_plan.py backend/tests/pipeline/test_outdoor_lane_plan.py
git commit -m "feat(lane): outdoor_lane_plan 프롬프트 팩 v1 + evidence-bound LLM 러너"
```

### Task A3: Step 클래스 + 플래그 + 디스패치 등록

**Files:**
- Create: `backend/app/core/steps/outdoor_lane_plan_step.py`
- Modify: `backend/app/core/config.py` (`outdoor_lane_plan_enabled: bool = False` — 기존 `outdoor_map_conti_enabled` 필드 옆)
- Modify: `backend/app/services/analysis_dispatch_service.py` (step_id `outdoor_lane_plan` 등록 — `outdoor_place_spec` 등록 라인과 동일 패턴)
- Test: `backend/tests/steps/test_outdoor_lane_plan_step.py`

**Interfaces:**
- Consumes: Task A2 `run_outdoor_lane_plan_group`; 기존 checkpoint 로딩 패턴(`outdoor_shot_grounding_step.py:49` `_load_prev_checkpoint`), `outdoor_direct_common.build_selected_keys/build_shot_loc_map/filter_group_shots`
- Produces: checkpoint `checkpoints/episodes/<eid>/outdoor_lane_plan/manifest.json` — `data.groups[gid] = {"plan": …, "attempts": n}` 또는 `{"skipped": reason}`; `config_hash`에 model+schema_version+prompt_version+flag 포함

- [ ] **Step 1: 실패 테스트 작성** — Step 게이트/스킵/집계만 결정론 검증 (기존 `tests/steps/`의 OutdoorPlaceSpecStep 테스트 파일 스타일을 따라: flag OFF→no-op empty manifest, spec 결측 그룹→skipped 비실패, 그룹 실패 격리→failed_count 증가, config_hash에 prompt_version 반영). 실제 파일은 그 스타일 확인 후 작성 — 케이스 4개 고정: `test_flag_off_noop`, `test_spec_missing_group_skipped`, `test_group_failure_isolated`, `test_config_hash_includes_prompt_version`.

- [ ] **Step 2: 실패 확인** — `cd backend && .venv/bin/pytest tests/steps/test_outdoor_lane_plan_step.py -v` → FAIL

- [ ] **Step 3: Step 구현** — `outdoor_shot_grounding_step.py`(OutdoorShotGroundingStep)를 원형으로: flag 게이트(`settings.outdoor_lane_plan_enabled`), outdoor_place_spec 그룹 로드(spec 있는 그룹만 대상, canon 불요 — lane plan은 맵 이미지가 필요 없음), scene_save 전문+shot_staging/shot_selection/shot_validator/scene_director 로드, 그룹 루프에서 `filter_group_shots`→`run_outdoor_lane_plan_group` 호출(그룹 단위 실패 격리), manifest 저장.

- [ ] **Step 4: 통과 확인** — 신규 테스트 + 전체 회귀: `cd backend && .venv/bin/pytest tests/ -q` → 전부 passed (기존 1027 + 신규)

- [ ] **Step 5: Commit**

```bash
git add backend/app/core/steps/outdoor_lane_plan_step.py backend/app/core/config.py backend/app/services/analysis_dispatch_service.py backend/tests/steps/test_outdoor_lane_plan_step.py
git commit -m "feat(lane): OutdoorLanePlanStep — flag 게이트·그룹 격리·checkpoint manifest"
```

### Task A4: 5회차 실측 실행 + 58샷 audit HTML + Codex 리뷰

**Files:**
- Create: `<scratchpad>/run_lane_plan.py` (run_recipe_step.py 복제 + `OUTDOOR_LANE_PLAN_ENABLED=true`, 커밋 금지)
- Create: `<scratchpad>/build_lane_audit.py` (audit HTML 생성, 커밋 금지)

- [ ] **Step 1**: backend cwd + `.venv` 절대경로로 `outdoor_lane_plan` 스텝 실행 (5회차 8ad94c03/e666c62b, 프로세스 로컬 env)
- [ ] **Step 2**: audit HTML — 야외 전 선택 샷 표: 샷 텍스트, 세그먼트, dominant_mode, lane, confidence, evidence 인용, routed_conservatively 여부. 8899 서버 docroot에 배치, LAN IP URL로 제시
- [ ] **Step 3**: 경계 3샷 육안 확인 — S11sh5=structure_plate·S15sh6=map_marker 기대, S26sh5=evidence 검토. 불일치 시 프롬프트 미세조정 반복(신규 버전 디렉토리) — PASS 카운트 보고 금지, evidence 원문 제시
- [ ] **Step 4**: Codex에 Stage A diff 코드 리뷰 요청(`[claude로부터 - 요청]`), 지적 반영 커밋
- [ ] **Step 5**: 사용자에게 audit 갤러리 URL 공유 + 분류 acceptance 합의 → Stage B 착수 결정

## Self-Review 결과

- 스펙 커버리지: 설계 v2의 게이트=Stage A로 잠금, 레인1/2/3 구현은 Stage B~D 로드맵에 명시(착수 시 별도 플랜 — scope check에 따른 의도적 분리). 누락 없음.
- 플레이스홀더: Task A3 Step 1/3은 기존 스텝 테스트 스타일 확인 후 작성으로 명시(케이스 4개 고정) — 기존 코드베이스 관례 준수가 목적이며 구현 내용은 원형 파일 참조로 특정됨.
- 타입 일관성: `run_outdoor_lane_plan_group` 시그니처가 A2 Produces와 A3 Consumes에서 동일. `LANES`/`routed_conservatively` 키 A1↔A4 일치.
