# Phase 2 — chain_bg shot guide single-call Implementation Plan

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

**Goal:** `background_chain_render` LLM이 한 번의 호출에서 PNG 생성용 `t2i_prompt`와 노드 안 각 샷에 대한 spatial guide(`shot_guides[]`)를 함께 출력하고, `scene_detail`이 그 guide를 `user_prompt`에 prepend하여 chain_bg 안 가구 중복/공간 사고를 줄인다. Phase 1b `shot_essence_extraction` consumer 통합도 포함.

**Architecture:** vision/별도 step 0건. chain_bg_render의 schema에 `shot_guides` 필드만 추가. `SceneContextLoader`가 manifest에서 로드, `_analyze_one`이 toggle 분기로 prepend. `chain_bg_guide_enabled` + `shot_essence_enabled` 두 토글 default off.

**Tech Stack:** Python 3.11, pytest, gpt-5.5 (text), Pydantic v2 BaseSettings.

**Spec:** `docs/2026-04-29-phase2-chain-bg-guide-design.md`

---

## File Structure

| 파일 | 책임 | 변경 종류 |
|---|---|---|
| `backend/app/core/config.py` | 토글 정의 | 1 line 추가 |
| `backend/app/core/dto/scene_analysis.py` | ctx 필드 정의 | 2 fields 추가 |
| `prompts/_base/background_chain_render/2.<ts>/system.md` | LLM에게 shot_guides 작성 지시 | 신규 파일 |
| `prompts/_base/background_chain_render/2.<ts>/schema.json` | output schema | 신규 파일 |
| `backend/app/modules/pipeline/background_chain_render.py` | LLM 반환 + manifest에 shot_guides 보존 | 수정 |
| `backend/app/core/steps/scene_context_loader.py` | 두 loader 메서드 신설 + load_all 호출 | 수정 |
| `backend/app/core/steps/detail_steps.py::_analyze_one` | prepend 블록 2개 (essence + chain_bg_guide) | 수정 |
| `backend/tests/core/test_chain_bg_guide.py` | 단위 테스트 15개 | 신규 |

**기존 prompt v1(`1.202604271700`)은 보존**. `prompt_loader._version_sort_key`가 가장 높은 버전을 자동 선택.

---

## Task 1: Settings toggle 추가

**Files:**
- Modify: `backend/app/core/config.py:47` (shot_essence_enabled 다음 줄)
- Test: `backend/tests/core/test_chain_bg_guide.py` (신규)

- [ ] **Step 1.1: 신규 테스트 파일 + 첫 테스트 작성**

`backend/tests/core/test_chain_bg_guide.py` 생성:

```python
"""Phase 2 — chain_bg shot guide consumer + Phase 1b essence consumer 통합 테스트."""
from __future__ import annotations

import json
from typing import Any, Dict, List
from unittest.mock import patch

import pytest


def test_settings_toggle_default_false():
    """default off 회귀 0건 보장."""
    from app.core.config import settings
    assert settings.chain_bg_guide_enabled is False
```

- [ ] **Step 1.2: 테스트 실행해서 실패 확인**

```bash
cd /Users/manta/Documents/Projects/TheRoad-I1/backend
.venv/bin/pytest tests/core/test_chain_bg_guide.py::test_settings_toggle_default_false -v
```
Expected: FAIL — `AttributeError: 'Settings' object has no attribute 'chain_bg_guide_enabled'`

- [ ] **Step 1.3: config.py에 toggle 추가**

`backend/app/core/config.py:47` 부근, `shot_essence_enabled` 라인 다음에 추가:

```python
    chain_bg_guide_enabled: bool = False    # Phase 2: chain_bg_render LLM이 출력한 shot_guides를 scene_detail user_prompt에 prepend. False면 prepend 0 (회귀 보장)
```

- [ ] **Step 1.4: 테스트 통과 확인**

```bash
.venv/bin/pytest tests/core/test_chain_bg_guide.py::test_settings_toggle_default_false -v
```
Expected: PASS

- [ ] **Step 1.5: 이번 task만 commit하지 말고 Task 8까지 모인 후 일괄 commit. 다음 task 진행.**

---

## Task 2: SceneAnalysisContext에 두 field 추가

**Files:**
- Modify: `backend/app/core/dto/scene_analysis.py` (field 2개 추가)

- [ ] **Step 2.1: 테스트 추가**

`backend/tests/core/test_chain_bg_guide.py`에 추가:

```python
def test_scene_analysis_context_has_phase2_fields():
    """Phase 2 + Phase 1b consumer 통합용 ctx field 존재 확인."""
    from app.core.dto.scene_analysis import SceneAnalysisContext
    ctx = SceneAnalysisContext()
    # Phase 2: chain_bg_guide consumer
    assert hasattr(ctx, "chain_bg_guide_by_shot")
    assert ctx.chain_bg_guide_by_shot == {}
    # Phase 1b consumer 통합
    assert hasattr(ctx, "essence_by_shot")
    assert ctx.essence_by_shot == {}
```

- [ ] **Step 2.2: 실행해서 실패 확인**

```bash
.venv/bin/pytest tests/core/test_chain_bg_guide.py::test_scene_analysis_context_has_phase2_fields -v
```
Expected: FAIL (AttributeError)

- [ ] **Step 2.3: scene_analysis.py에 field 2개 추가**

`backend/app/core/dto/scene_analysis.py`의 dataclass `SceneAnalysisContext` 안, `planning_context: Optional[Any] = None` 다음에:

```python
    # ── Phase 2 — chain_bg_render의 shot_guides ─────────────
    # (scene_index, shot_index) → guide str (영어 자연어 한 단락)
    chain_bg_guide_by_shot: Dict[Tuple[int, int], str] = field(default_factory=dict)

    # ── Phase 1b — shot_essence_extraction consumer ─────────
    # (scene_index, shot_index) → essence list. status='failed' shot은 빈 list
    essence_by_shot: Dict[Tuple[int, int], List[str]] = field(default_factory=dict)
```

- [ ] **Step 2.4: 테스트 통과 확인**

```bash
.venv/bin/pytest tests/core/test_chain_bg_guide.py::test_scene_analysis_context_has_phase2_fields -v
```
Expected: PASS

- [ ] **Step 2.5: 다음 task로 진행 (commit 아직)**

---

## Task 3: prompt v2 신설 (background_chain_render)

**Files:**
- Create: `prompts/_base/background_chain_render/2.<YYYYMMDDHHMM>/system.md`
- Create: `prompts/_base/background_chain_render/2.<YYYYMMDDHHMM>/schema.json`

**버전 결정**: 작업 시각으로 결정. 예: `2.202604291400`(2026-04-29 14:00). 기존 v1(`1.202604271700`)은 보존 — `prompt_loader._version_sort_key`가 가장 높은 버전을 자동 선택.

- [ ] **Step 3.1: prompt 자동 선택 동작 확인 테스트 추가**

`backend/tests/core/test_chain_bg_guide.py`:

```python
def test_chain_bg_render_prompt_v2_loaded():
    """v2 자동 선택 + 새 schema에 shot_guides field 확인."""
    from app.modules.prompt_loader import load_prompt, load_schema
    sys = load_prompt("background_chain_render", "system")
    assert "shot_guides" in sys.lower()
    assert "do not redraw" in sys.lower()

    sch = load_schema("background_chain_render", "schema")
    assert "shot_guides" in sch["properties"]
    assert sch["properties"]["shot_guides"]["type"] == "array"
    items = sch["properties"]["shot_guides"]["items"]
    assert set(items["required"]) == {"shot_id", "guide"}
    assert sch["required"] == ["t2i_prompt", "shot_guides"]
```

- [ ] **Step 3.2: 실행해서 실패 확인**

```bash
.venv/bin/pytest tests/core/test_chain_bg_guide.py::test_chain_bg_render_prompt_v2_loaded -v
```
Expected: FAIL — current schema has only `t2i_prompt`

- [ ] **Step 3.3: 디렉토리 생성**

```bash
mkdir -p prompts/_base/background_chain_render/2.202604291400
```
(시각은 작업 시 결정. 위는 예시)

- [ ] **Step 3.4: 기존 v1 파일을 v2로 복사 + system.md 보강**

먼저 기존 v1 system.md를 읽어 그대로 복사:

```bash
cp prompts/_base/background_chain_render/1.202604271700/system.md \
   prompts/_base/background_chain_render/2.202604291400/system.md
cp prompts/_base/background_chain_render/1.202604271700/user_template.md \
   prompts/_base/background_chain_render/2.202604291400/user_template.md
```

(`user_template.md`도 함께 복사 — 변경 X. v2 디렉토리에 모든 파일 모음)

- [ ] **Step 3.5: 새 system.md 끝에 shot_guides 섹션 추가**

`prompts/_base/background_chain_render/2.202604291400/system.md` 파일 맨 끝에 다음 섹션 append:

```markdown

## shot_guides 출력 (v2 신규)

본 노드의 chain_bg PNG가 reference로 주어졌을 때, 노드 안 각 샷의 t2i 작성자에게
줄 자연어 가이드를 함께 출력한다.

### 작성 원칙

1. **가구 상대 위치 명시** — "TV is in the upper-left of the frame", "sofa cluster
   in the center", "window on the right side". 좌표/픽셀 X. 자연어로 frame 상대
   위치만.
2. **DO NOT redraw 명령 포함** — "DO NOT redraw the TV/sofa/window — they will
   appear in the chain_bg reference". 영어 명령형으로 명시.
3. **샷 description 기반으로 인물/소품 배치 힌트** — "Position the character standing
   in front of the sofa, gazing toward TV". 인물 자세는 essence가 결정하므로 여기서는
   chain_bg 가구와의 spatial 관계만.
4. **영어로 작성** — t2i prompt에 직접 prepend되므로 영어.
5. **100~250단어** — 짧고 명확. 카탈로그식 묘사 금지.
6. **shot_ids 1:1 대응** — 노드의 모든 shot_ids에 대해 guide 1개씩. 누락/추가 금지.

### 출력 형식

```json
{
  "t2i_prompt": "<기존 그대로 — PNG 생성용>",
  "shot_guides": [
    {
      "shot_id": "S5_Shot1",
      "guide": "TV occupies upper-left of frame. Sofa-table cluster centered, slightly forward. Window on right with daylight. Position character standing in front of sofa, gaze toward upper-left toward TV. DO NOT redraw the TV/sofa/window — they will appear in the chain_bg reference."
    }
  ]
}
\```
```

(주의: 마지막 ``` 는 디렉티브 escape를 위해 \``` 로 표시. 실제 파일에는 ``` 만)

- [ ] **Step 3.6: 새 schema.json 작성**

`prompts/_base/background_chain_render/2.202604291400/schema.json` 신규:

```json
{
  "type": "object",
  "additionalProperties": false,
  "properties": {
    "t2i_prompt": {
      "type": "string",
      "description": "PNG 생성용 prompt (영어, 자연어 한 단락)"
    },
    "shot_guides": {
      "type": "array",
      "description": "노드 안 각 샷의 chain_bg 기준 spatial guide. shot_ids와 1:1 대응.",
      "items": {
        "type": "object",
        "additionalProperties": false,
        "properties": {
          "shot_id": {
            "type": "string",
            "description": "S{scene}_Shot{idx} 형식. 본 노드의 shot_ids 중 하나여야 함."
          },
          "guide": {
            "type": "string",
            "description": "이 chain_bg가 reference로 주어졌을 때 shot t2i 작성자에게 줄 자연어 한 단락 (영어). 가구 상대 위치 + DO NOT redraw 명령 + 인물 배치 힌트 포함. 100~250단어."
          }
        },
        "required": ["shot_id", "guide"]
      }
    }
  },
  "required": ["t2i_prompt", "shot_guides"]
}
```

- [ ] **Step 3.7: 테스트 통과 확인**

```bash
.venv/bin/pytest tests/core/test_chain_bg_guide.py::test_chain_bg_render_prompt_v2_loaded -v
```
Expected: PASS

---

## Task 4: background_chain_render.py 코드 수정

**Files:**
- Modify: `backend/app/modules/pipeline/background_chain_render.py:117-146` (`generate_node_prompt`)
- Modify: `backend/app/modules/pipeline/background_chain_render.py:421-444` (enriched_nodes 저장 부분)

**핵심 변경**: `generate_node_prompt`가 string 대신 `Tuple[str, List[Dict]]` 반환. `render_one_location`이 enriched_nodes에 `shot_guides` 저장.

- [ ] **Step 4.1: 테스트 추가 — generate_node_prompt 반환 형식**

`backend/tests/core/test_chain_bg_guide.py`:

```python
def test_generate_node_prompt_returns_t2i_and_shot_guides():
    """LLM이 t2i_prompt + shot_guides 둘 다 반환하면 둘 다 추출."""
    from app.modules.pipeline import background_chain_render as bcr

    fake_llm_result = {
        "t2i_prompt": "Living room view. ASCII only.",
        "shot_guides": [
            {"shot_id": "S5_Shot1", "guide": "TV upper-left. Sofa center. DO NOT redraw."},
            {"shot_id": "S5_Shot3", "guide": "TV upper-left. Position character on sofa. DO NOT redraw."},
        ],
    }

    with patch.object(bcr, "call_structured", return_value=fake_llm_result):
        with patch("app.modules.pipeline.background_chain_render.load_prompt", return_value="sys"):
            with patch("app.modules.pipeline.background_chain_render.load_schema", return_value={}):
                t2i, guides = bcr.generate_node_prompt(
                    location_id="L01",
                    location_description="Living room",
                    node={"id": "anchor_root", "kind": "anchor_root", "label": "x", "description": "y", "shot_ids": ["S5_Shot1", "S5_Shot3"], "shared_visual_anchors_with_parent": []},
                    parent=None,
                    shots_in_node=[
                        {"shot_id": "S5_Shot1", "description": "..."},
                        {"shot_id": "S5_Shot3", "description": "..."},
                    ],
                )
    assert t2i == "Living room view. ASCII only."
    assert len(guides) == 2
    assert guides[0]["shot_id"] == "S5_Shot1"
    assert "DO NOT redraw" in guides[0]["guide"]
```

- [ ] **Step 4.2: 실행해서 실패 확인**

```bash
.venv/bin/pytest tests/core/test_chain_bg_guide.py::test_generate_node_prompt_returns_t2i_and_shot_guides -v
```
Expected: FAIL — 현재 generate_node_prompt는 string 1개 반환

- [ ] **Step 4.3: `generate_node_prompt` 수정**

`backend/app/modules/pipeline/background_chain_render.py:117-146` 의 `generate_node_prompt` 함수를 다음으로 교체:

```python
def generate_node_prompt(
    location_id: str,
    location_description: str,
    node: Dict[str, Any],
    parent: Optional[Dict[str, Any]],
    shots_in_node: List[Dict[str, Any]],
    opik_metadata: Optional[Dict[str, Any]] = None,
) -> Tuple[str, List[Dict[str, Any]]]:
    """노드 1개에 대한 t2i prompt + shot_guides 생성. 영어 ASCII만 검증.

    Returns:
        (t2i_prompt, shot_guides)
        - t2i_prompt: PNG 생성용 prompt (영어 한 단락)
        - shot_guides: list of {"shot_id": str, "guide": str}. v2 schema 미지원 prompt
          버전 사용 시 빈 list 반환 (legacy fallback).
    """
    system = load_prompt("background_chain_render", "system")
    schema = load_schema("background_chain_render", "schema")
    user = _build_user_prompt_for_node(
        location_id, location_description, node, parent, shots_in_node,
    )

    result = call_structured(
        step="background_chain_render",
        system_prompt=system,
        user_prompt=user,
        response_schema=schema,
        opik_metadata=opik_metadata,
    )
    t2i = (result.get("t2i_prompt") or "").strip()
    if _NON_ASCII_TEXT_RE.search(t2i):
        raise ValueError(
            f"node {node.get('id')!r} t2i_prompt contains non-ASCII text "
            f"(Korean/Hanja/kana detected — universal-noun rule violated)"
        )
    # v2 schema: shot_guides 추출. 누락 시 빈 list (v1 호환 fallback)
    shot_guides_raw = result.get("shot_guides") or []
    shot_guides: List[Dict[str, Any]] = []
    if isinstance(shot_guides_raw, list):
        for sg in shot_guides_raw:
            if not isinstance(sg, dict):
                continue
            sid = sg.get("shot_id") or ""
            guide = sg.get("guide") or ""
            if sid and guide:
                shot_guides.append({"shot_id": sid, "guide": guide})
    return t2i, shot_guides
```

- [ ] **Step 4.4: 호출자 `render_one_location` 업데이트**

`backend/app/modules/pipeline/background_chain_render.py`의 `render_one_location` 함수 안 line 346~371 부근 (Phase 1: t2i prompt 호출 부분)을 다음으로 교체:

```python
        # Phase 1: t2i prompt + shot_guides
        try:
            t2i_prompt, shot_guides = generate_node_prompt(
                location_id=location_id,
                location_description=location_description,
                node=node,
                parent=parent,
                shots_in_node=shots_in_node,
                opik_metadata=opik_metadata,
            )
        except Exception as exc:
            logger.error(
                "render_one_location: %s/%s prompt gen failed: %s",
                location_id, node_id, exc,
            )
            enriched_nodes[node_id] = {
                **node,
                "t2i_prompt": "",
                "image_path": "",
                "render_status": "prompt_failed",
                "render_attempts": 0,
                "sanitize_strategies": [],
                "render_error": str(exc)[:200],
                "shot_guides": [],
            }
            failed_nodes += 1
            continue
```

그리고 ok 분기 (line 423~433 부근)와 failed 분기 (line 434~444)에 `shot_guides` 필드 추가:

```python
        if result["status"] == "ok":
            rendered_paths[node_id] = out_path
            enriched_nodes[node_id] = {
                **node,
                "t2i_prompt": t2i_prompt,
                "image_path": str(out_path),
                "render_status": "ok",
                "render_attempts": result["attempts"],
                "sanitize_strategies": result["strategies"],
                "ref_used": ref_used,
                "shot_guides": shot_guides,
            }
        else:
            enriched_nodes[node_id] = {
                **node,
                "t2i_prompt": t2i_prompt,
                "image_path": "",
                "render_status": "failed",
                "render_attempts": result["attempts"],
                "sanitize_strategies": result["strategies"],
                "render_error": result.get("final_block_reason") or "",
                "ref_used": ref_used,
                "shot_guides": shot_guides,
            }
            failed_nodes += 1
```

- [ ] **Step 4.5: 테스트 통과 확인**

```bash
.venv/bin/pytest tests/core/test_chain_bg_guide.py::test_generate_node_prompt_returns_t2i_and_shot_guides -v
```
Expected: PASS

- [ ] **Step 4.6: legacy v1 호환 테스트 추가**

```python
def test_generate_node_prompt_handles_legacy_response_without_shot_guides():
    """v1 prompt response (shot_guides 없음) → 빈 list fallback."""
    from app.modules.pipeline import background_chain_render as bcr

    legacy_response = {"t2i_prompt": "Living room view."}  # no shot_guides

    with patch.object(bcr, "call_structured", return_value=legacy_response):
        with patch("app.modules.pipeline.background_chain_render.load_prompt", return_value="sys"):
            with patch("app.modules.pipeline.background_chain_render.load_schema", return_value={}):
                t2i, guides = bcr.generate_node_prompt(
                    location_id="L01",
                    location_description="x",
                    node={"id": "anchor_root", "kind": "anchor_root", "label": "x", "description": "y", "shot_ids": [], "shared_visual_anchors_with_parent": []},
                    parent=None,
                    shots_in_node=[],
                )
    assert t2i == "Living room view."
    assert guides == []
```

- [ ] **Step 4.7: 실행 → 통과**

```bash
.venv/bin/pytest tests/core/test_chain_bg_guide.py::test_generate_node_prompt_handles_legacy_response_without_shot_guides -v
```
Expected: PASS

---

## Task 5: scene_context_loader._load_chain_bg_guide_by_shot()

**Files:**
- Modify: `backend/app/core/steps/scene_context_loader.py` (load_all 호출 + 신규 메서드)

- [ ] **Step 5.1: 테스트 6개 추가 (chain_bg_guide 부분)**

```python
def test_loader_returns_empty_when_no_chain_bg_render_checkpoint():
    """chain_bg_render manifest 없으면 빈 dict."""
    from app.core.steps.scene_context_loader import SceneContextLoader

    class FakeRunner:
        def _load_prev_checkpoint(self, step_id):
            return None  # 모든 step manifest 없음

    loader = SceneContextLoader(FakeRunner())
    result = loader._load_chain_bg_guide_by_shot()
    assert result == {}


def test_loader_parses_shot_guides_from_manifest():
    """manifest 파싱 → (si, shi) → guide str."""
    from app.core.steps.scene_context_loader import SceneContextLoader

    cp_data = {
        "data": {
            "locations": {
                "L01": {
                    "nodes": [
                        {
                            "id": "anchor_root",
                            "shot_ids": ["S5_Shot1", "S5_Shot3"],
                            "shot_guides": [
                                {"shot_id": "S5_Shot1", "guide": "TV upper-left. Sofa center."},
                                {"shot_id": "S5_Shot3", "guide": "TV upper-left. Char on sofa."},
                            ],
                        },
                    ],
                },
            },
        },
    }

    class FakeRunner:
        def _load_prev_checkpoint(self, step_id):
            return cp_data if step_id == "background_chain_render" else None

    loader = SceneContextLoader(FakeRunner())
    result = loader._load_chain_bg_guide_by_shot()
    assert result == {
        (5, 1): "TV upper-left. Sofa center.",
        (5, 3): "TV upper-left. Char on sofa.",
    }


def test_loader_handles_legacy_checkpoint_without_shot_guides(caplog):
    """v1 manifest (shot_guides 필드 없음) → 빈 dict + warning."""
    import logging
    from app.core.steps.scene_context_loader import SceneContextLoader

    cp_data = {
        "data": {
            "locations": {
                "L01": {
                    "nodes": [
                        {"id": "anchor_root", "shot_ids": ["S5_Shot1"]},  # no shot_guides
                    ],
                },
            },
        },
    }

    class FakeRunner:
        def _load_prev_checkpoint(self, step_id):
            return cp_data if step_id == "background_chain_render" else None

    loader = SceneContextLoader(FakeRunner())
    with caplog.at_level(logging.WARNING):
        result = loader._load_chain_bg_guide_by_shot()
    assert result == {}


def test_loader_handles_extra_shot_ids(caplog):
    """입력 shot_ids에 없는 extra shot_id는 무시 + warning."""
    import logging
    from app.core.steps.scene_context_loader import SceneContextLoader

    cp_data = {
        "data": {
            "locations": {
                "L01": {
                    "nodes": [
                        {
                            "id": "anchor_root",
                            "shot_ids": ["S5_Shot1"],
                            "shot_guides": [
                                {"shot_id": "S5_Shot1", "guide": "ok"},
                                {"shot_id": "S99_Shot99", "guide": "extra — ignored"},
                            ],
                        },
                    ],
                },
            },
        },
    }

    class FakeRunner:
        def _load_prev_checkpoint(self, step_id):
            return cp_data if step_id == "background_chain_render" else None

    loader = SceneContextLoader(FakeRunner())
    with caplog.at_level(logging.WARNING):
        result = loader._load_chain_bg_guide_by_shot()
    assert result == {(5, 1): "ok"}
    assert (99, 99) not in result


def test_loader_handles_duplicate_shot_ids():
    """중복 shot_id → 첫 번째만 (Phase 1b _merge_with_input_keys 패턴)."""
    from app.core.steps.scene_context_loader import SceneContextLoader

    cp_data = {
        "data": {
            "locations": {
                "L01": {
                    "nodes": [
                        {
                            "id": "anchor_root",
                            "shot_ids": ["S5_Shot1"],
                            "shot_guides": [
                                {"shot_id": "S5_Shot1", "guide": "first"},
                                {"shot_id": "S5_Shot1", "guide": "second — ignored"},
                            ],
                        },
                    ],
                },
            },
        },
    }

    class FakeRunner:
        def _load_prev_checkpoint(self, step_id):
            return cp_data if step_id == "background_chain_render" else None

    loader = SceneContextLoader(FakeRunner())
    result = loader._load_chain_bg_guide_by_shot()
    assert result == {(5, 1): "first"}


def test_loader_handles_invalid_shot_id_format(caplog):
    """shot_id가 'S{int}_Shot{int}' 형식이 아니면 skip + warning."""
    import logging
    from app.core.steps.scene_context_loader import SceneContextLoader

    cp_data = {
        "data": {
            "locations": {
                "L01": {
                    "nodes": [
                        {
                            "id": "anchor_root",
                            "shot_ids": ["BAD_FORMAT"],
                            "shot_guides": [
                                {"shot_id": "BAD_FORMAT", "guide": "x"},
                            ],
                        },
                    ],
                },
            },
        },
    }

    class FakeRunner:
        def _load_prev_checkpoint(self, step_id):
            return cp_data if step_id == "background_chain_render" else None

    loader = SceneContextLoader(FakeRunner())
    with caplog.at_level(logging.WARNING):
        result = loader._load_chain_bg_guide_by_shot()
    assert result == {}
```

- [ ] **Step 5.2: 실행 → 6개 모두 실패 확인**

```bash
.venv/bin/pytest tests/core/test_chain_bg_guide.py -v -k "loader_"
```
Expected: 모두 FAIL — `_load_chain_bg_guide_by_shot` 미존재

- [ ] **Step 5.3: scene_context_loader.py에 메서드 추가**

`backend/app/core/steps/scene_context_loader.py` 파일에 다음을 삽입:

(1) 파일 상단 import에 추가 (이미 있으면 skip):
```python
import re
```

(2) class 안 메서드 추가 (예: `_load_planning_context` 다음 줄):

```python
    def _load_chain_bg_guide_by_shot(self) -> Dict[Tuple[int, int], str]:
        """background_chain_render manifest의 nodes[].shot_guides를
        (scene_index, shot_index) → guide str 매핑으로 펼침.

        - manifest 미존재 → {}
        - shot_guides 필드 없는 legacy 노드 → skip + warning
        - input shot_ids에 없는 extra shot_id → skip + warning
        - 중복 shot_id → 첫 번째만
        - 'S{int}_Shot{int}' 형식 미준수 shot_id → skip + warning
        """
        cp = self.runner._load_prev_checkpoint("background_chain_render")
        result: Dict[Tuple[int, int], str] = {}
        if not (cp and cp.get("data", {}).get("locations")):
            return result

        sid_re = re.compile(r"^S(\d+)_Shot(\d+)$")
        warnings = 0
        legacy_nodes = 0
        for loc_id, loc_data in cp["data"]["locations"].items():
            for node in loc_data.get("nodes", []) or []:
                guides = node.get("shot_guides")
                if guides is None:
                    legacy_nodes += 1
                    continue
                input_shot_ids = set(node.get("shot_ids") or [])
                for sg in guides:
                    sid = (sg or {}).get("shot_id") or ""
                    guide = (sg or {}).get("guide") or ""
                    if not sid or not guide:
                        warnings += 1
                        continue
                    if sid not in input_shot_ids:
                        logger.warning(
                            "_load_chain_bg_guide_by_shot: extra shot_id %r not in node %s shot_ids — skip",
                            sid, node.get("id"),
                        )
                        warnings += 1
                        continue
                    m = sid_re.match(sid)
                    if not m:
                        logger.warning(
                            "_load_chain_bg_guide_by_shot: invalid shot_id format %r — skip", sid,
                        )
                        warnings += 1
                        continue
                    key = (int(m.group(1)), int(m.group(2)))
                    if key in result:
                        # 중복 → 첫 번째만 보존
                        continue
                    result[key] = guide
        if legacy_nodes:
            logger.warning(
                "_load_chain_bg_guide_by_shot: %d legacy nodes had no shot_guides field "
                "(v1 prompt). chain_bg_guide prepend will be 0 for those.",
                legacy_nodes,
            )
        if result:
            logger.info(
                "_load_chain_bg_guide_by_shot: %d shot guides loaded (%d skipped warnings)",
                len(result), warnings,
            )
        return result
```

(3) `load_all` 메서드 안에 호출 추가 (planning_context 다음 줄):

```python
        ctx.chain_bg_guide_by_shot = self._load_chain_bg_guide_by_shot()
```

- [ ] **Step 5.4: 6개 테스트 통과 확인**

```bash
.venv/bin/pytest tests/core/test_chain_bg_guide.py -v -k "loader_"
```
Expected: 6 PASSED

---

## Task 6: scene_context_loader._load_essence_by_shot() (Phase 1b consumer 통합)

**Files:**
- Modify: `backend/app/core/steps/scene_context_loader.py` (메서드 1개 추가 + load_all 호출)

- [ ] **Step 6.1: 테스트 추가**

```python
def test_loader_parses_essence_by_shot_from_manifest():
    """shot_essence_extraction 체크포인트 → (si, shi) → essence list."""
    from app.core.steps.scene_context_loader import SceneContextLoader

    cp_data = {
        "data": {
            "shots": [
                {"scene_index": 5, "shot_index": 1, "essence": ["여인이 무릎을 꿇고 손을 뻗는다", "선명한 핏자국"], "peripheral": [], "atmospheric": [], "status": "ok"},
                {"scene_index": 5, "shot_index": 3, "essence": ["여인이 일어선다"], "peripheral": [], "atmospheric": [], "status": "ok"},
                {"scene_index": 12, "shot_index": 1, "essence": [], "peripheral": [], "atmospheric": [], "status": "failed"},
            ],
        },
    }

    class FakeRunner:
        def _load_prev_checkpoint(self, step_id):
            return cp_data if step_id == "shot_essence_extraction" else None

    loader = SceneContextLoader(FakeRunner())
    result = loader._load_essence_by_shot()
    assert result == {
        (5, 1): ["여인이 무릎을 꿇고 손을 뻗는다", "선명한 핏자국"],
        (5, 3): ["여인이 일어선다"],
        # (12, 1) is status='failed' with empty essence → not mapped
    }


def test_loader_returns_empty_when_no_shot_essence_checkpoint():
    """Phase 1b step 미실행 → 빈 dict."""
    from app.core.steps.scene_context_loader import SceneContextLoader

    class FakeRunner:
        def _load_prev_checkpoint(self, step_id):
            return None

    loader = SceneContextLoader(FakeRunner())
    result = loader._load_essence_by_shot()
    assert result == {}
```

- [ ] **Step 6.2: 실행 → 실패 확인**

```bash
.venv/bin/pytest tests/core/test_chain_bg_guide.py -v -k "essence_by_shot"
```
Expected: FAIL

- [ ] **Step 6.3: scene_context_loader.py에 메서드 추가**

`_load_chain_bg_guide_by_shot` 다음 줄에 추가:

```python
    def _load_essence_by_shot(self) -> Dict[Tuple[int, int], List[str]]:
        """shot_essence_extraction (Phase 1b) 체크포인트의 data.shots[]를
        (scene_index, shot_index) → essence list 매핑으로 펼침.

        - status='failed' shot은 빈 essence가 stub으로 들어있음 → skip
        - 빈 essence shot은 prepend 의미 없으므로 skip
        """
        cp = self.runner._load_prev_checkpoint("shot_essence_extraction")
        result: Dict[Tuple[int, int], List[str]] = {}
        if not (cp and cp.get("data", {}).get("shots")):
            return result
        for sh in cp["data"]["shots"]:
            status = sh.get("status", "ok")
            essence = sh.get("essence") or []
            si = sh.get("scene_index")
            shi = sh.get("shot_index")
            if status != "ok" or not essence or si is None or shi is None:
                continue
            result[(int(si), int(shi))] = list(essence)
        if result:
            logger.info(
                "_load_essence_by_shot: %d shots with essence loaded", len(result),
            )
        return result
```

(4) `load_all` 메서드에 호출 추가 (`chain_bg_guide_by_shot` 다음 줄):

```python
        ctx.essence_by_shot = self._load_essence_by_shot()
```

- [ ] **Step 6.4: 테스트 통과 확인**

```bash
.venv/bin/pytest tests/core/test_chain_bg_guide.py -v -k "essence_by_shot"
```
Expected: 2 PASSED

---

## Task 7: detail_steps._analyze_one prepend 2개

**Files:**
- Modify: `backend/app/core/steps/detail_steps.py:330` 부근 (user_prompt 시작 직후)

**핵심 변경**: `_analyze_one` 내 user_prompt 초기화 직후 essence + chain_bg_guide prepend 2 블록 추가. 두 토글 독립.

- [ ] **Step 7.1: 설계 결정 — 헬퍼 함수 추출 + 단위 테스트**

`_analyze_one`은 ctx 의존이 많아 직접 테스트하면 fixture가 비대해진다. 따라서 prepend 블록 빌드 로직을 **순수 함수 `_build_phase2_prepend_blocks`로 추출**하고 그 단위만 테스트한다. `_analyze_one`은 그 헬퍼를 호출하기만 한다 (smoke 회귀는 Step 7.8에서 기존 detail_steps 테스트로 검증).

- [ ] **Step 7.2: detail_steps에 prepend helper 함수 추출**

`backend/app/core/steps/detail_steps.py` 파일 상단(클래스 밖)에 헬퍼 신설:

```python
def _build_phase2_prepend_blocks(
    si: int,
    shi: int,
    essence_by_shot: Dict[Tuple[int, int], List[str]],
    chain_bg_guide_by_shot: Dict[Tuple[int, int], str],
    *,
    shot_essence_enabled: bool,
    chain_bg_guide_enabled: bool,
) -> str:
    """Phase 1b essence + Phase 2 chain_bg_guide prepend 블록 생성.

    두 토글이 모두 off면 빈 문자열 반환 → 회귀 0 보장.
    essence 먼저, chain_bg_guide 뒤. 사이에 빈 줄.
    """
    blocks: List[str] = []

    if shot_essence_enabled:
        essence = essence_by_shot.get((si, shi)) or []
        if essence:
            lines = "\n".join(f"- {e}" for e in essence)
            blocks.append(
                "[샷 핵심 시각 요소 — 반드시 t2i_prompt에 포함]\n" + lines
            )

    if chain_bg_guide_enabled:
        guide = chain_bg_guide_by_shot.get((si, shi)) or ""
        if guide:
            blocks.append(
                "[chain_bg reference에 이미 있음 — 다시 그리지 말 것]\n" + guide
            )

    if not blocks:
        return ""
    return "\n\n".join(blocks) + "\n\n"
```

(타입 import도 위쪽에 추가: `from typing import Dict, Tuple, List`가 이미 있으면 skip)

- [ ] **Step 7.3: 헬퍼 단위 테스트 추가 (Step 7.1의 4 테스트 대체)**

위 Step 7.1의 4 테스트를 다음으로 교체:

```python
def test_build_prepend_blocks_both_off():
    """두 토글 다 off → 빈 문자열 (회귀 보장)."""
    from app.core.steps.detail_steps import _build_phase2_prepend_blocks
    out = _build_phase2_prepend_blocks(
        si=5, shi=1,
        essence_by_shot={(5, 1): ["x"]},
        chain_bg_guide_by_shot={(5, 1): "g"},
        shot_essence_enabled=False,
        chain_bg_guide_enabled=False,
    )
    assert out == ""


def test_build_prepend_blocks_essence_only():
    """essence on + chain_bg_guide off → essence 블록만."""
    from app.core.steps.detail_steps import _build_phase2_prepend_blocks
    out = _build_phase2_prepend_blocks(
        si=5, shi=1,
        essence_by_shot={(5, 1): ["여인이 무릎을 꿇는다", "핏자국"]},
        chain_bg_guide_by_shot={(5, 1): "ignored"},
        shot_essence_enabled=True,
        chain_bg_guide_enabled=False,
    )
    assert "[샷 핵심 시각 요소" in out
    assert "여인이 무릎을 꿇는다" in out
    assert "핏자국" in out
    assert "[chain_bg" not in out
    assert out.endswith("\n\n")


def test_build_prepend_blocks_chain_bg_only():
    """chain_bg_guide on + essence off → chain_bg 블록만."""
    from app.core.steps.detail_steps import _build_phase2_prepend_blocks
    out = _build_phase2_prepend_blocks(
        si=5, shi=1,
        essence_by_shot={(5, 1): ["ignored"]},
        chain_bg_guide_by_shot={(5, 1): "TV upper-left. DO NOT redraw."},
        shot_essence_enabled=False,
        chain_bg_guide_enabled=True,
    )
    assert "[chain_bg reference에 이미 있음" in out
    assert "TV upper-left" in out
    assert "DO NOT redraw" in out
    assert "[샷 핵심" not in out


def test_build_prepend_blocks_both_on_essence_first():
    """둘 다 on → essence 먼저, chain_bg 뒤."""
    from app.core.steps.detail_steps import _build_phase2_prepend_blocks
    out = _build_phase2_prepend_blocks(
        si=5, shi=1,
        essence_by_shot={(5, 1): ["e1"]},
        chain_bg_guide_by_shot={(5, 1): "g1"},
        shot_essence_enabled=True,
        chain_bg_guide_enabled=True,
    )
    e_idx = out.find("[샷 핵심")
    c_idx = out.find("[chain_bg")
    assert 0 <= e_idx < c_idx, f"essence should appear before chain_bg: {out!r}"


def test_build_prepend_blocks_missing_data_returns_empty():
    """토글 on이지만 data 없음 → 빈 문자열."""
    from app.core.steps.detail_steps import _build_phase2_prepend_blocks
    out = _build_phase2_prepend_blocks(
        si=5, shi=1,
        essence_by_shot={},
        chain_bg_guide_by_shot={},
        shot_essence_enabled=True,
        chain_bg_guide_enabled=True,
    )
    assert out == ""


def test_build_prepend_blocks_handles_failed_status_essence():
    """essence가 빈 list (Phase 1b status='failed' fallback) → essence 블록 0."""
    from app.core.steps.detail_steps import _build_phase2_prepend_blocks
    out = _build_phase2_prepend_blocks(
        si=5, shi=1,
        essence_by_shot={(5, 1): []},  # empty list
        chain_bg_guide_by_shot={(5, 1): "g"},
        shot_essence_enabled=True,
        chain_bg_guide_enabled=True,
    )
    assert "[샷 핵심" not in out
    assert "[chain_bg" in out
```

- [ ] **Step 7.4: 실행 → 6 테스트 실패 확인**

```bash
.venv/bin/pytest tests/core/test_chain_bg_guide.py -v -k "build_prepend_blocks"
```
Expected: 6 FAIL — `_build_phase2_prepend_blocks` 미존재 (헬퍼 추가 안 됨)

- [ ] **Step 7.5: 위 Step 7.2의 헬퍼 코드를 detail_steps.py에 실제 추가**

(이미 Step 7.2에서 코드 작성. 여기서 실제 파일에 적용)

- [ ] **Step 7.6: 헬퍼 테스트 6개 통과 확인**

```bash
.venv/bin/pytest tests/core/test_chain_bg_guide.py -v -k "build_prepend_blocks"
```
Expected: 6 PASSED

- [ ] **Step 7.7: `_analyze_one`에 헬퍼 호출 추가**

`backend/app/core/steps/detail_steps.py:330` 부근의 `_analyze_one` 메서드 안, `user_prompt = (` 직전(즉 user_prompt 초기 빌드 직전):

```python
        # Phase 2: essence (Phase 1b consumer) + chain_bg_guide prepend
        from app.core.config import settings as _settings
        prepend_blocks = _build_phase2_prepend_blocks(
            si=seg.get("scene_index"),
            shi=shot_info.get("shot_index", shot_info.get("_shot_index", 1)) if shot_info else 1,
            essence_by_shot=ctx.essence_by_shot,
            chain_bg_guide_by_shot=ctx.chain_bg_guide_by_shot,
            shot_essence_enabled=_settings.shot_essence_enabled,
            chain_bg_guide_enabled=_settings.chain_bg_guide_enabled,
        )

        user_prompt = (
            prepend_blocks
            + "..."  # (기존 user_prompt 빌드 코드 그대로)
        )
```

**주의**: `_analyze_one`의 정확한 user_prompt 빌드 라인은 line 330~601. 그 빌드 가장 앞에 `prepend_blocks +` 만 추가. 기존 코드 다른 부분 변경 X.

구체적으로 line 330 부근:

```python
        user_prompt = (
            f"\n[현재 분석 대상]\n"
            ...
        )
```

→ 다음으로 변경:

```python
        user_prompt = prepend_blocks + (
            f"\n[현재 분석 대상]\n"
            ...
        )
```

- [ ] **Step 7.8: 회귀 — 기존 detail_steps 테스트 통과 확인**

```bash
.venv/bin/pytest tests/core/ -v -k "detail" 2>&1 | tail -30
```
Expected: 기존 테스트 모두 PASS (회귀 0)

---

## Task 8: 회귀 baseline + 듀얼 코드 리뷰 + commit

**Files:**
- 변경 없음, 검증 + 리뷰 + commit

- [ ] **Step 8.1: 전체 테스트 통과 확인**

```bash
cd /Users/manta/Documents/Projects/TheRoad-I1/backend
.venv/bin/pytest tests/core/test_chain_bg_guide.py -v
```
Expected: 15 PASSED (Task 1~7의 모든 테스트)

- [ ] **Step 8.2: manifest baseline 회귀 0건 확인**

```bash
.venv/bin/pytest tests/test_step_manifest_v3.py tests/test_manifest_fields.py tests/test_step_catalog.py -v 2>&1 | tail -40
```
Expected: 모두 PASS, count baseline (51 steps) 변경 없음 — Phase 2는 step 신설 X.

- [ ] **Step 8.3: Phase 1b 테스트 통과 확인 (회귀 0)**

```bash
.venv/bin/pytest tests/core/test_shot_essence_extraction.py -v
```
Expected: 10 PASSED (Phase 1b 기존 테스트 그대로)

- [ ] **Step 8.4: 듀얼 코드 리뷰 (Codex + Claude 병렬)**

CLAUDE.md 절대 규칙: 커밋 전 듀얼 리뷰 필수.

```bash
# 1. Codex 리뷰
codex exec "본 변경사항(Phase 2 — chain_bg shot guide single-call)을 리뷰해. spec: docs/2026-04-29-phase2-chain-bg-guide-design.md, plan: docs/2026-04-29-phase2-chain-bg-guide-plan.md. High/Medium 이상의 이슈를 찾아내고 fix 권장사항 제시."
```

(별도로 Claude 리뷰는 본 세션 또는 별도 agent에서 수행)

리뷰 결과 처리:
- **Critical/High 이슈**: 모두 수정 후 재리뷰
- **Medium 이슈**: 사용자 검토 후 선택적 수정
- **Low 이슈**: 메모로 보존

- [ ] **Step 8.5: 변경 사항 수정 후 재테스트**

리뷰에서 수정 사항이 있으면 적용 후:

```bash
.venv/bin/pytest tests/core/test_chain_bg_guide.py tests/core/test_shot_essence_extraction.py tests/test_step_manifest_v3.py -v
```
Expected: 모두 PASS

- [ ] **Step 8.6: git status 확인 + commit**

```bash
git status
git diff --stat
```

신규 + 수정 파일 확인:
- M backend/app/core/config.py
- M backend/app/core/dto/scene_analysis.py
- A prompts/_base/background_chain_render/2.<ts>/system.md
- A prompts/_base/background_chain_render/2.<ts>/schema.json
- A prompts/_base/background_chain_render/2.<ts>/user_template.md (v1 복사본)
- M backend/app/modules/pipeline/background_chain_render.py
- M backend/app/core/steps/scene_context_loader.py
- M backend/app/core/steps/detail_steps.py
- A backend/tests/core/test_chain_bg_guide.py
- A docs/2026-04-29-phase2-chain-bg-guide-design.md
- A docs/2026-04-29-phase2-chain-bg-guide-plan.md

- [ ] **Step 8.7: spec + plan + 코드 한 commit으로**

```bash
git add docs/2026-04-29-phase2-chain-bg-guide-design.md \
        docs/2026-04-29-phase2-chain-bg-guide-plan.md \
        backend/app/core/config.py \
        backend/app/core/dto/scene_analysis.py \
        prompts/_base/background_chain_render/2.* \
        backend/app/modules/pipeline/background_chain_render.py \
        backend/app/core/steps/scene_context_loader.py \
        backend/app/core/steps/detail_steps.py \
        backend/tests/core/test_chain_bg_guide.py

git commit -m "$(cat <<'EOF'
feat(phase2): chain_bg_render LLM이 shot_guides 함께 출력 + scene_detail consumer 통합

핵심: vision/별도 step 0건. background_chain_render LLM 1회 호출에서 t2i_prompt + shot_guides[]를 함께 출력. scene_detail이 toggle 분기로 prepend.

변경:
- prompts/_base/background_chain_render/2.<ts>: schema에 shot_guides 추가 + system 보강
- background_chain_render.generate_node_prompt: 반환 dict로
- scene_context_loader: _load_chain_bg_guide_by_shot + _load_essence_by_shot (Phase 1b consumer 통합)
- detail_steps._analyze_one: _build_phase2_prepend_blocks 헬퍼 호출하여 prepend
- config: chain_bg_guide_enabled 토글 (default off)

회귀 보장 3중:
- chain_bg_guide_enabled / shot_essence_enabled 둘 다 default False
- legacy v1 prompt manifest (shot_guides 누락) → 빈 dict fallback + warning
- chain_bg_render LLM 자체 PNG 생성 결과는 영향 0 (output 토큰만 ~$0.01/EP 추가)

테스트: backend/tests/core/test_chain_bg_guide.py (15 cases)
- toggle default
- ctx field 존재
- prompt v2 자동 선택
- generate_node_prompt 반환 + legacy fallback
- loader 6 cases (정상/empty/legacy/extra/duplicate/invalid format)
- essence loader 2 cases
- _build_phase2_prepend_blocks 6 cases (off/essence/chain_bg/both/missing/failed)

spec: docs/2026-04-29-phase2-chain-bg-guide-design.md
plan: docs/2026-04-29-phase2-chain-bg-guide-plan.md

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

- [ ] **Step 8.8: push 권한 확인 후 push**

⚠️ **사용자 명시 확인 후 push** (브랜치 main 직접 push이므로):

```bash
git status
git log --oneline -5
```

push는 사용자가 명시 요청 시에만:

```bash
git push origin main
```

- [ ] **Step 8.9: 메모리 갱신**

`/Users/manta/.claude/projects/-Users-manta-Documents-Projects-TheRoad-I1/memory/`에 새 메모리 파일 작성:

`session_20260429_phase2.md` 신규:
- 본 task 결과 요약
- 토글 사용법 (env: `CHAIN_BG_GUIDE_ENABLED=true`, `SHOT_ESSENCE_ENABLED=true`)
- 다음 세션 = Phase 3 (location_floor_plan step 신설)
- v3 plan section 11에서 본 Phase 진행 표시

`MEMORY.md`에 인덱스 항목 추가.

---

## Self-Review

### Spec coverage 확인

- [x] Spec §3 흐름: Task 3, 4, 5에서 구현
- [x] Spec §5 데이터 모델: Task 2 (ctx field), Task 3 (schema), Task 4 (manifest)
- [x] Spec §6 변경 파일: Task 1~7에 모두 매핑
- [x] Spec §7 Phase 1b essence 통합: Task 6 + Task 7
- [x] Spec §8 토글 매트릭스: Task 7의 _build_phase2_prepend_blocks 4 케이스에서 검증
- [x] Spec §9 회귀 보장 3중: Task 8 회귀 테스트
- [x] Spec §10 Edge cases 9건: Task 5 6 cases + Task 6 + Task 7 6 cases = 13 케이스로 커버
- [x] Spec §12 테스트 15개: Task 1(1) + 2(1) + 3(1) + 4(2) + 5(6) + 6(2) + 7(6) = 19 테스트 (spec 15개 + 추가 4)
- [x] Spec §13 듀얼 코드 리뷰: Task 8.4

### Placeholder scan
- prompt 버전 `2.<YYYYMMDDHHMM>` — 실 작업 시 시각으로 결정. 예시 `2.202604291400` 명시.
- `_analyze_one` user_prompt 빌드 line `330~601` — 정확한 line은 코드 변경 시 확인. 핵심은 `prepend_blocks +` 를 가장 앞에 prefix하는 것. 명확.

### Type consistency
- `chain_bg_guide_by_shot: Dict[Tuple[int, int], str]` — Task 2, 5, 7 모두 일치
- `essence_by_shot: Dict[Tuple[int, int], List[str]]` — Task 2, 6, 7 모두 일치
- `_build_phase2_prepend_blocks` 시그니처 — Task 7 정의/호출/테스트 모두 일치

### Spec 누락 → 추가 필요
없음. spec의 모든 요구사항이 task로 매핑됨.

---

## Execution Handoff

다음 두 가지 실행 옵션:

**1. Subagent-Driven (recommended)** — 각 task별로 fresh subagent 디스패치, task 사이 리뷰. 빠른 iteration.

**2. Inline Execution** — 본 세션에서 task 순서대로 직접 실행. 체크포인트마다 일시정지.

다음 세션에서 어느 방식을 선택할지는 사용자 결정.
