# Phase 5 — Background Planner Redesign Design

> **Sister doc**: `docs/2026-04-29-phase5-background-planner-plan.md` (구현 task 분해)
> **Predecessor**: Phase 3 (location_floor_plan, commits `5ba005d`→`4eac12c`) + Phase 4 (chain_bg multi-image, `5af9a6c`→`8f6d9f1`)
> **Trigger**: Phase 3 production 검증(18/18 도면 OK)에서 사용자 결정 — "전부를 할 필요 없어"

---

## 1. 목표 (Goal)

`background_mode=floor_plan_anchored` 활성 시 배경 자산을 **샷 빈도 기반 + LLM 기반 sequential pipeline**으로 생성. 모든 location 무차별 생성을 중단하고:

1. **3+ 샷 + 건물 내부**만 도면 + chain_bg 생성
2. **2 샷 또는 야외**는 prev_shot_ref hybrid fallback 사용
3. 도면/chain_bg 생성 순서는 **LLM이 정한 순서** 따라 sequential
4. 도면 sequential 호출 시 **이전 도면 컨텍스트 multi-turn** 전달 (gpt-5.5 텍스트), **이미지 ref**는 stateless이므로 prev PNG 명시 첨부

**비목표:**
- 시나리오 도메인 코드/이름 추가 X (범용 유지)
- mode=`off` / `chain_only` 회귀 0건 (default 그대로)
- DB 스키마 변경 X (ImageAsset asset_type/short_id 패턴 그대로)

---

## 2. 핵심 결정 (User-confirmed)

### 결정 매트릭스

| Decision | 사용자 답변 | 근거 |
|---|---|---|
| 빈도 기준 | **샷 기반** (selected shot count) | 사용자 명시 |
| 그룹/순서 결정자 | **LLM** (gpt-5.5) | 코드 룰 X — LLM이 outdoor/indoor + parent_id 판단 |
| 샷 정보 전달 방식 | **모든 selected shot description 한 번에** | gpt-5.5 1M context로 충분 |
| 도면 멀티턴 | **텍스트 멀티턴 OK** + 이미지 별도 ref 첨부 | gpt-5.5 multi-turn ≠ gpt-image-2 stateless |
| chain_bg 그룹 처리 | **그룹별 sequential** | parent → child 의존성 LLM 결정 |
| 코드 재사용 | **Phase 3+4 ~60% 재사용** | 사용자 명시 |
| **HARD 제약** | **"한 번에 모든 배경 작성 불가야 단계를 만들어야 해"** | 각 도면/chain_bg = **별도 LLM call**, 체크포인트로 분리 |

### Frequency Filter (명세)

| 선택된 샷 수 | location 유형 | floor_plan | chain_bg | render fallback |
|:---:|:---|:---:|:---:|:---|
| 1 | (any) | ❌ | ❌ | scene 단독 (entity refs만) |
| 2 | (any) | ❌ | ❌ | prev_shot_ref hybrid |
| 3+ | outdoor | ❌ | ❌ (가능하면) | prev_shot_ref 우선 |
| 3+ | indoor | ✅ | ✅ | 도면 + chain_bg multi-image edit |

**indoor + 외부 묶음 규칙**: planner가 building_group으로 indoor 도면과 같이 indoor의 외부도 단일 floor plan 묶음 (예: L05 옥탑방 내부 + L11 옥탑방 외부 계단 = 같은 floor_plan PNG의 다른 영역 또는 같은 building_group 내 분리 도면).

---

## 3. 4-Step Pipeline 흐름

```
[기존 Phase 3+4]
  background_chain_planning (19.6, location 무차별)
  location_floor_plan         (21.5, location 무차별 ThreadPool)
  background_chain_render     (24.7, location 무차별 ThreadPool)

[Phase 5 redesign]
  background_planner          (19.55, NEW, 1 LLM call)
    ├─ analyses ALL selected shots in one prompt
    ├─ outputs: floor_plans[], floor_plan_order[], chain_bg_groups[], chain_bg_order[], prev_shot_only[]
    └─ skip if mode != floor_plan_anchored
  location_floor_plan         (21.5, REVISED — sequential per planner.floor_plan_order)
    └─ multi-turn text context + prev PNG ref attachment
  background_chain_planning   (19.6 → 21.55, REVISED — group-based)
    ├─ moved AFTER location_floor_plan to consume floor_plan prompts
    └─ planner.chain_bg_groups iterate, parent prompt context
  background_chain_render     (24.7, REVISED — chain_bg_order sequential)
    └─ Phase 4 ref priority 그대로 (floor_plan + parent/location)
```

**Order 변경 핵심**:
- `background_planner=19.55` (analysis 영역, chain_bg_planning 직전)
- `background_chain_planning=21.55` (image>analysis 불변식 우회 위해 location_floor_plan 직후 analysis로 위치) — **Phase 3 C1 fix와 동일 패턴**
- 또는 `background_chain_planning=19.6` 유지 + on_demand 순서 의존성 docstring 강화 (Phase 4 I1 패턴)
- **선택**: Phase 4 I1 패턴 재활용 → manifest 의존 X, 코드 내부 best-effort 로딩 (안전한 mode=off 회귀 보장)

---

## 4. background_planner Step 상세 (NEW)

### 입력 의존 (depends_on)

```python
"background_planner": {
    "label": "배경 생성 Planner",
    "category": "analysis",
    "order": 19.55,
    "default_model": "gpt-5.5",
    "provider": "openai",
    "depends_on": ["shot_validator", "shot_selection", "scene_director",
                   "entity_merge", "entity_detail", "visual_world_rules"],
    "fan_out": False,
    "applicability": "if_floor_plan_mode",  # mode=off → not_applicable
    "step_type": "analysis",
    "lifecycle": "active",
},
```

### Pipeline 모듈 (`pipeline/background_planner.py`)

**핵심 함수:**
```python
def build_planner_user_prompt(
    selected_shots_by_scene: Dict[int, List[Dict]],
    location_canon_by_short: Dict[str, EntityCanon],
    location_label_by_short: Dict[str, str],
    entity_short_ids: List[str],   # runtime enum용
    location_short_ids: List[str],
    visual_world_rules: str,
) -> str:
    """모든 selected shot을 단일 프롬프트로 직렬화. shot 수 30~100개도 1M context 안전."""

def run_background_planner(
    project_config: Dict,
    user_prompt: str,
    location_short_ids: List[str],   # runtime enum
    opik_metadata: Dict,
    call_structured_fn,
) -> Dict[str, Any]:
    """1 LLM call. structured output (planner_schema). retry 3회."""

def validate_planner_output(plan: Dict, all_location_ids: List[str], all_shot_ids: List[str]) -> None:
    """semantic invariant 검증. 한글 0건, parent_id 유효성, execution_order 부모-before-자식, 누락 0건."""
```

### Step 클래스 (`steps/background_planner_step.py`)

```python
class BackgroundPlannerStep(StepRunner):
    step_id = "background_planner"

    def _execute(self, mode="resume") -> Dict[str, Any]:
        # 1. mode=off → not_applicable 반환 (applicability validator로 자동)
        # 2. 의존 체크포인트 로드:
        #    - shot_validator: scenes[].shots[]
        #    - shot_selection: scenes[].selected_shot_indices
        #    - scene_director: scenes[].primary_location, scenes[].present_entity_ids
        #    - entity_merge: locations[], characters[], props[]
        #    - entity_detail: location.description, kind (indoor/outdoor 보강)
        #    - visual_world_rules: rules + director_notes
        # 3. selected shot 추출 + scene context 매핑
        # 4. user_prompt build → call_structured → output schema
        # 5. validate_planner_output (semantic invariant)
        # 6. 체크포인트 저장: data = planner output
        # 7. 반환: {"applicable_count": 1, "completed_count": 1, "failed_count": 0, "data": ...}
```

### Output Schema (`prompts/_base/background_planner/1.YYYYMMDDHHmm/schema.json`)

```json
{
  "type": "object",
  "additionalProperties": false,
  "properties": {
    "rationale_summary": {"type": "string", "description": "분석 요약 1~2문장 한국어"},
    "floor_plans": {
      "type": "array",
      "items": {
        "type": "object",
        "additionalProperties": false,
        "properties": {
          "id":             {"type": "string", "description": "도면 ID, e.g. 'FP_L05'"},
          "building_group": {"type": "string", "description": "같은 건물 그룹 식별자, e.g. 'okt_room'"},
          "location_ids":   {"type": "array", "items": {"type": "string"}, "description": "이 도면에 포함되는 location short_id 목록 (실내+관련 외부)"},
          "primary_location_id": {"type": "string", "description": "도면의 주 location"},
          "rationale":      {"type": "string", "description": "왜 이 도면이 필요한지 1줄 한국어"},
          "shot_count":     {"type": "integer", "description": "포함 location들의 selected shot 수 합계"}
        },
        "required": ["id", "building_group", "location_ids", "primary_location_id", "rationale", "shot_count"]
      }
    },
    "floor_plan_order": {
      "type": "array",
      "items": {"type": "string"},
      "description": "도면 ID 순서. 같은 building_group은 인접하게."
    },
    "chain_bg_groups": {
      "type": "array",
      "items": {
        "type": "object",
        "additionalProperties": false,
        "properties": {
          "id":             {"type": "string", "description": "chain_bg 노드 ID, e.g. 'CB_L05_living_day'"},
          "floor_plan_id":  {"type": "string", "description": "참조하는 floor_plan id"},
          "location_id":    {"type": "string"},
          "scenes":         {"type": "array", "items": {"type": "integer"}, "description": "scene_index 목록"},
          "shot_ids":       {"type": "array", "items": {"type": "string"}, "description": "S{scene}_Shot{shot} 형식"},
          "kind":           {"type": "string", "enum": ["anchor_root", "anchor_state"]},
          "parent_id":      {"type": "string", "description": "부모 chain_bg id, root면 빈 문자열"},
          "time":           {"type": "string", "description": "day/dusk/night/dawn/none"},
          "rationale":      {"type": "string"}
        },
        "required": ["id", "floor_plan_id", "location_id", "scenes", "shot_ids", "kind", "parent_id", "time", "rationale"]
      }
    },
    "chain_bg_order": {
      "type": "array",
      "items": {"type": "string"},
      "description": "chain_bg id 실행 순서. parent 전 child 금지."
    },
    "prev_shot_only": {
      "type": "array",
      "items": {
        "type": "object",
        "additionalProperties": false,
        "properties": {
          "location_id": {"type": "string"},
          "shot_count":  {"type": "integer"},
          "kind":        {"type": "string", "enum": ["outdoor_3+", "low_freq_2", "single_shot"]},
          "rationale":   {"type": "string"}
        },
        "required": ["location_id", "shot_count", "kind", "rationale"]
      }
    }
  },
  "required": ["rationale_summary", "floor_plans", "floor_plan_order", "chain_bg_groups", "chain_bg_order", "prev_shot_only"]
}
```

**Runtime enum 주입** (scene_director v2 패턴):
- `floor_plans[].location_ids[]` → enum=all_location_ids
- `floor_plans[].primary_location_id` → enum=all_location_ids
- `chain_bg_groups[].location_id`, `floor_plan_id` → enum=알려진 ID
- `chain_bg_groups[].shot_ids[]` → enum=`{S{scene}_Shot{idx} for selected shots}`
- `prev_shot_only[].location_id` → enum=all_location_ids

### System Prompt (요지)

```
You are a background generation planner for a film storyboard pipeline.
Analyze ALL selected shots in the episode and decide which locations require
detailed floor plans + chain background images, vs which can rely on previous
shot references.

## Frequency Rules (HARD)
- 1 selected shot → never floor_plan, never chain_bg
- 2 selected shots → never floor_plan, never chain_bg (handled by prev_shot_ref)
- 3+ shots OUTDOOR → never floor_plan, prefer prev_shot_ref
- 3+ shots INDOOR → REQUIRES floor_plan + chain_bg

## Building Group Rule
If an indoor location has related outdoor counterparts (e.g. interior of an
apartment + its rooftop staircase), group them in the SAME floor_plan entry
(building_group). The floor plan PNG will visualize both.

## Order Constraints
- floor_plan_order: same building_group consecutive
- chain_bg_order: parent before any child
- chain_bg_groups[].parent_id must reference an earlier id in chain_bg_order
- root chain_bg (parent_id="") = the canonical state for that floor_plan

## Output Discipline
- All IDs in English snake_case (e.g. CB_L05_living_day)
- rationale fields: 1 short Korean sentence
- No proper nouns from any specific scenario (universal)
```

### User Prompt Template (요지)

```
[VISUAL WORLD RULES]
{visual_world_rules}

[ALL LOCATIONS]
{location_lines}   # L01 (label, kind=indoor/outdoor): description...

[SELECTED SHOTS BY SCENE]
{scene_blocks}     # Scene N (primary_location=L05): Shot1 desc / Shot2 desc / ...

[TASK]
Decide floor_plans, chain_bg_groups, prev_shot_only following the rules.
```

---

## 5. location_floor_plan Redesign (Sequential Multi-turn)

### 변경 요약

| 항목 | Phase 3 | Phase 5 |
|---|---|---|
| 처리 순서 | location 단위 ThreadPool(max_workers=4) 병렬 | planner.floor_plan_order 순차 |
| 처리 대상 | 모든 location (no filter) | planner.floor_plans[].id만 |
| 멀티턴 | 없음 (single shot) | gpt-5.5에 prev 도면 prompt summary 텍스트 컨텍스트 |
| 이미지 ref | 없음 (text-to-image) | 같은 building_group의 prev PNG → gpt-image-2 multi-image edit |
| floor_plan ↔ location 매핑 | 1:1 (short_id == filename) | 1:N (floor_plan id == filename, location_ids[] 매핑) |
| 체크포인트 데이터 구조 | `floor_plans[].location_id` | `floor_plans[].id, location_ids[], building_group` |

### 새 처리 알고리즘 (의사코드)

```python
def _execute(mode="resume"):
    planner_cp = self._load_prev_checkpoint("background_planner")
    if not planner_cp or not planner_cp["data"].get("floor_plans"):
        return {"applicable_count": 0, ...}  # planner skip 또는 빈 결과

    fp_specs = {fp["id"]: fp for fp in planner_cp["data"]["floor_plans"]}
    order = planner_cp["data"]["floor_plan_order"]

    rendered_summaries: Dict[str, str] = {}   # fp_id → prompt summary (~1줄)
    rendered_paths: Dict[str, Path] = {}      # fp_id → PNG path
    results: Dict[str, Dict] = {}

    for fp_id in order:                       # SEQUENTIAL
        spec = fp_specs[fp_id]
        # 1. multi-turn text context 빌드
        prev_summaries = [
            (other_id, rendered_summaries[other_id])
            for other_id in rendered_summaries
            if other_id != fp_id
        ]
        # 2. 같은 building_group의 prev PNG 모음
        same_group_refs = [
            rendered_paths[other_id]
            for other_id, other_spec in fp_specs.items()
            if other_id in rendered_paths and other_spec["building_group"] == spec["building_group"]
        ]
        # 3. 단일 floor_plan = 단일 LLM call (HARD 제약 충족)
        prompt = _build_one_floor_plan_prompt_v2(spec, prev_summaries, ...)
        prompt_text = generate_floor_plan_prompt(...)              # gpt-5.5 retry
        png_bytes = generate_floor_plan_image(
            prompt_text,
            ref_paths=same_group_refs,                              # NEW: multi-image edit
            image_model="gpt-image-2",
        )
        png_path = image_dir / f"{fp_id}.png"
        png_path.write_bytes(png_bytes)
        rendered_paths[fp_id] = png_path
        rendered_summaries[fp_id] = _summarize_prompt(prompt_text)  # ~1줄
        results[fp_id] = {"status": "ok", "prompt_text": prompt_text, "png_path": str(png_path), ...}

    self._register_image_assets(results, fp_specs, location_canon_by_short)
    return {"applicable_count": len(order), "completed_count": len(results), ...}
```

### Prompt v2 (`prompts/_base/location_floor_plan/2.YYYYMMDDHHmm/`)

**system.md 변화:**
- v1 system 그대로 유지 + "PREVIOUS FLOOR PLANS" 섹션 처리 지시 추가:
  ```
  If a [PREVIOUS FLOOR PLANS] block is provided, maintain visual consistency
  for buildings/eras/style families. Same architectural style for related
  buildings; distinct style for unrelated buildings.
  ```

**user_template.md 변화:**
- 기존 5 placeholders + 신규 2 placeholders:
  - `{previous_floor_plans_block}`: 이전 도면 prompt 1줄 요약 list (없으면 빈 문자열)
  - `{building_group_context}`: 같은 building_group의 prev fp_id 리스트 (없으면 빈 문자열)

### `generate_floor_plan_image()` 시그니처 변경

```python
# Phase 3 (현재)
def generate_floor_plan_image(prompt: str, openai_client, model: str = "gpt-image-2", size="1024x1024", quality="high") -> bytes:
    # images.generate (text-only)

# Phase 5 (변경)
def generate_floor_plan_image(
    prompt: str,
    openai_client,
    ref_paths: List[Path] = None,           # NEW
    model: str = "gpt-image-2",
    size: str = "1024x1024",
    quality: str = "high",
) -> bytes:
    if not ref_paths:
        # text-only: images.generate
    else:
        # multi-image edit: images.edit (Phase 4 ExitStack 패턴)
```

---

## 6. background_chain_planning Redesign (Group-driven)

### 변경 요약

| 항목 | Phase 4 | Phase 5 |
|---|---|---|
| 그룹 결정 | `_phase0_group_by_location` (primary_location 기반 코드 룰) | `planner.chain_bg_groups`만 사용 |
| location 단위 LLM | 1 location = 1 call | 1 group = 1 call (location은 group 내 location_id) |
| location 무차별 처리 | ✅ 모든 selected shot location | ❌ planner가 정한 group만 |
| chain tree 결정 | LLM이 self-design (parent_id 자동) | LLM은 group 내부 prompt만 생성 (parent_id는 planner가 정함) |
| floor_plan prompt prepend | best-effort | always (planner가 floor_plan_id 명시) |

### 새 알고리즘

```python
def run_background_chain_planning(...):
    planner = load_planner_cp()
    if not planner:
        return {"applicable_count": 0, ...}

    groups = planner["chain_bg_groups"]                  # planner-driven
    floor_plan_prompts = _load_floor_plan_prompts()      # location_floor_plan에서, fp_id → prompt
    parent_prompts: Dict[str, str] = {}                  # group_id → planning result

    results: Dict[str, Dict] = {}
    for group_id in planner["chain_bg_order"]:           # SEQUENTIAL — parent before child
        group = next(g for g in groups if g["id"] == group_id)
        # 1. floor_plan prompt prepend (Phase 4 패턴 그대로)
        fp_prompt = floor_plan_prompts.get(group["floor_plan_id"], "")
        # 2. parent chain_bg prompt context (있으면 NEW)
        parent_ctx = parent_prompts.get(group["parent_id"], "") if group["parent_id"] else ""
        # 3. 단일 group = 단일 LLM call (HARD 제약 충족)
        result = _plan_one_group(group, fp_prompt, parent_ctx, ...)
        parent_prompts[group_id] = result["chain_bg_prompt"]
        results[group_id] = result

    return {"completed_count": len(results), ..., "data": {"groups": results}}
```

### Prompt 변화

`prompts/_base/background_chain_planning/N+1.YYYYMMDDHHmm/`:
- **system.md**: 기존 + "PARENT CHAIN BG" 섹션 처리 지시 추가
- **user_template.md**: 기존 + `{parent_chain_bg_block}` placeholder

---

## 7. background_chain_render Redesign (chain_bg_order Sequential)

### 변경 요약

| 항목 | Phase 4 | Phase 5 |
|---|---|---|
| 처리 단위 | location 단위 (각 location 내부 sequential) | chain_bg_order 단위 sequential |
| 병렬화 | location 간 ThreadPool | 제거 (단일 chain dependency tree) |
| ref 우선순위 | floor_plan(1) → parent/location(2) → text_only | 그대로 유지 |
| 입력 | planning.locations[] | planning.groups[] (planner-driven) |

### 알고리즘 (간략)

```python
def run_background_chain_render(...):
    planner = load_planner_cp()
    planning = load_chain_bg_planning_cp()
    floor_plan_paths = _load_floor_plan_paths()        # fp_id → Path

    rendered_paths: Dict[str, Path] = {}

    for group_id in planner["chain_bg_order"]:         # SEQUENTIAL
        group = planning["groups"][group_id]
        ref_paths = []
        # 1순위: floor_plan PNG
        fp_path = floor_plan_paths.get(group["floor_plan_id"])
        if fp_path: ref_paths.append(fp_path)
        # 2순위: parent chain_bg PNG OR location ref (mutually exclusive)
        if group["parent_id"] and group["parent_id"] in rendered_paths:
            ref_paths.append(rendered_paths[group["parent_id"]])
        elif fp_path is None:                          # parent 없고 fp도 없으면 location ref
            location_ref = _resolve_location_ref(group["location_id"])
            if location_ref: ref_paths.append(location_ref)

        png = render_node_image(group["chain_bg_prompt"], ref_paths=ref_paths, ...)
        rendered_paths[group_id] = save_png(png, group_id)
```

`render_node_image` (Phase 4): **변경 없음** — 이미 ExitStack 기반 multi-image edit + ref_paths 단일/다중 분기 갖춤.

---

## 8. multi-turn 정의 (텍스트 vs 이미지)

| 영역 | 멀티턴 가능? | Phase 5 처리 |
|---|---|---|
| **gpt-5.5 텍스트** (planner / floor_plan prompt 생성 / chain_bg prompt 생성) | ✅ 가능 | 그러나 **각 step = 별도 call** (HARD 제약). 멀티턴 컨텍스트는 prev 결과를 user_prompt에 명시 inject 형태 (semantically multi-turn) |
| **gpt-image-2** (floor_plan PNG / chain_bg PNG) | ❌ stateless | prev PNG를 `images.edit` 호출 시 `image=[prev1.png, prev2.png, ...]` 명시 첨부. 호출 자체는 1회성 |

**즉 "멀티턴"의 의미:**
- 도면 sequential 호출 = 진짜 multi-turn 대화 X
- prev 결과를 다음 호출 user_prompt에 inject = "semantic multi-turn"
- 이미지는 ref 첨부로만 컨텍스트 전달

---

## 9. step_manifest 변경

### 추가
```python
"background_planner": {
    "label": "배경 생성 Planner",
    "category": "analysis",
    "order": 19.55,
    "default_model": "gpt-5.5",
    "provider": "openai",
    "depends_on": ["shot_validator", "shot_selection", "scene_director",
                   "entity_merge", "entity_detail", "visual_world_rules"],
    "fan_out": False,
    "applicability": "if_floor_plan_mode",
    "step_type": "analysis",
    "lifecycle": "active",
},
```

### 수정 (3개)

```python
"location_floor_plan": {
    # order 21.5 유지
    "depends_on": [..., "background_planner"],   # ADD
    # 나머지 동일
},
"background_chain_planning": {
    # order 19.6 유지 (Phase 4 I1 패턴: manifest 의존 X, 코드 best-effort)
    "depends_on": [..., "background_planner"],   # ADD
    # location_floor_plan은 manifest 의존 X (best-effort)
    # 나머지 동일
},
"background_chain_render": {
    # order 24.7 유지
    # depends_on은 location_floor_plan, background_chain_planning 그대로
},
```

`if_floor_plan_mode` validator(`app/core/applicability.py`)는 Phase 3 그대로 사용.

---

## 10. mode 매트릭스

| mode | background_planner | location_floor_plan | chain_bg_planning | chain_bg_render |
|---|:---:|:---:|:---:|:---:|
| `off` (default) | not_applicable | not_applicable | not_applicable | not_applicable |
| `chain_only` | not_applicable | not_applicable | applicable (legacy 그루핑) | applicable (단일 ref) |
| `floor_plan_anchored` | applicable | applicable (planner-driven) | applicable (planner-driven) | applicable (multi-image) |

`chain_only` 처리:
- planner skip → chain_bg_planning이 planner cp 없음 인지 → **legacy `_phase0_group_by_location` fallback 보존**
- chain_bg_render도 floor_plan_paths 빈 dict → 단일 ref 모드 (Phase 4 회귀 보장 그대로)

→ **legacy fallback 코드는 삭제 X**, `if planner_cp: planner_path else: legacy_path` 분기로 양립.

---

## 11. 회귀 보장 (3중)

1. **mode=off default**: applicability validator가 4 step 모두 `not_applicable` → 기존 production 동작 동일
2. **mode=chain_only**: planner skip + chain_bg legacy path → Phase 4 이전 회귀 결과 보존
3. **planner 결과가 빈 floor_plans / 빈 chain_bg_groups**: location_floor_plan/chain_bg가 0개 처리 → graceful no-op (Phase 4 빈 locations guard relax 패턴)

---

## 12. 변경 파일 목록 (예상 ~17개)

### 신규 (5)
1. `backend/app/core/steps/background_planner_step.py` (~250 LOC)
2. `backend/app/modules/pipeline/background_planner.py` (~300 LOC)
3. `prompts/_base/background_planner/1.{ts}/system.md`
4. `prompts/_base/background_planner/1.{ts}/user_template.md`
5. `prompts/_base/background_planner/1.{ts}/schema.json`

### 신규 prompt 버전 (2)
6. `prompts/_base/location_floor_plan/2.{ts}/system.md` + `user_template.md` (multi-turn 컨텍스트 추가)
7. `prompts/_base/background_chain_planning/N+1.{ts}/system.md` + `user_template.md` (parent chain context)

### **DB 변경 (NEW — C안 채택)**
8. `backend/alembic/versions/{rev}_phase5_image_asset_variants.py` — variant_index/label/t2i_guide 컬럼 추가 + unique constraint 확장
9. `backend/app/models/image_asset.py` — ORM 모델 업데이트

### 수정 (코드)
10. `backend/app/core/steps/__init__.py` — STEP_CLASSES 등록
11. `backend/app/core/step_manifest.py` — 1 신규 + 2 depends_on
12. `backend/app/core/steps/location_floor_plan_step.py` — sequential 루프 + variant=v00 영속
13. `backend/app/core/steps/background_chain_planning_step.py` — planner cp 로드 + group dispatch
14. `backend/app/core/steps/background_chain_render_step.py` — chain_bg_order driver + variant counter
15. `backend/app/modules/pipeline/location_floor_plan.py` — `generate_floor_plan_image` ref_paths 파라미터
16. `backend/app/modules/pipeline/background_chain_planning.py` — group-based 분기
17. `backend/app/modules/pipeline/background_chain_render.py` — chain_bg_order driver + ImageAsset UPSERT with t2i_guide
18. `backend/app/modules/llm/llm_client.py` — PIPELINE_STEPS에 `background_planner` 추가

### 신규 테스트 (3)
19. `backend/tests/core/test_background_planner_pipeline.py` (~10 tests)
20. `backend/tests/core/test_background_planner_step.py` (~8 tests)
21. `backend/tests/core/test_phase5_sequential_pipeline.py` (~5 통합 시나리오)
22. `backend/tests/db/test_image_asset_migration.py` (~3 tests)

### 회귀 테스트 갱신 (2)
23. `backend/tests/core/test_location_floor_plan.py` — sequential 모드 + ref_paths 케이스 추가
24. `backend/tests/core/test_phase4_floor_plan_chain.py` — group-based 분기 + variant counter 케이스 추가

---

## 13. 테스트 전략

### Unit (background_planner)
- frequency rule (1/2/3+ shot, indoor/outdoor) 분기 6개
- building_group 같이 묶기
- floor_plan_order 같은 group 인접
- chain_bg_order parent-before-child
- skip mode (mode=off → not_applicable)
- runtime enum 위반 시 LLM retry

### Unit (location_floor_plan sequential)
- planner cp 없으면 not_applicable
- planner.floor_plans 빈 list → graceful no-op
- floor_plan_order 순서대로 처리
- prev 도면 컨텍스트 inject (텍스트)
- 같은 building_group의 prev PNG ref 첨부
- ref_paths=[] → text-only fallback (Phase 3 회귀)

### Unit (chain_bg_planning + render)
- planner.chain_bg_groups 따라 처리
- legacy fallback (planner cp 없을 때) 동작
- parent prompt context 전달
- chain_bg_order sequential rendering
- floor_plan_paths={} → text_only (Phase 4 회귀)

### Integration (E2E sub-pipeline)
- mode=off: 4 step 모두 skip
- mode=chain_only: planner+floor_plan skip, chain_bg legacy
- mode=floor_plan_anchored: full sequential

### Production 검증 (다음 세션)
- PID c00bbe19 (금월도 EP1)에 mode=floor_plan_anchored 재실행 (force)
- 결과: 도면 수가 18 → ~3-5 (frequency filter 효과)
- 비교 페이지 갱신: planner output + floor plans + chain_bg PNGs

---

## 14. 비용 모델

| 항목 | mode=floor_plan_anchored Phase 3+4 | Phase 5 변화 |
|---|---|---|
| background_planner LLM | — | gpt-5.5 1 call (~10-20K input + 2-5K output) ≈ $0.15 |
| location_floor_plan LLM | gpt-5.5 × 18 (모든 location) | gpt-5.5 × ~5 (3+ indoor만) → -72% |
| location_floor_plan image | gpt-image-2 × 18 | gpt-image-2 × ~5 → -72% |
| chain_bg_planning LLM | gpt-5.5 × ~12 (per location) | gpt-5.5 × ~10 (per group, 약간 줄어듦) |
| chain_bg_render image | gpt-image-2 × ~12 | gpt-image-2 × ~10 |

**총 비용 추정 (EP당)**:
- Phase 3+4 floor_plan_anchored: ~$2.5
- Phase 5: ~$1.0 (~60% 절감)

**+ Phase 5에서 추가**: 같은 building_group의 prev PNG가 floor_plan ref로 첨부 → input token 약 30% 증가하나 호출 수 감소가 더 큼.

---

## 15. 의사결정 사항 (확정)

| # | 결정 | 사유 |
|:---:|---|---|
| 1 | background_planner = analysis (order 19.55, image>analysis 불변식 만족) | scene_director와 비슷한 위치, mode=off 시 not_applicable |
| 2 | **ImageAsset variant 패턴 (C안)**: short_id=location, variant_index/label/t2i_guide 컬럼 추가. floor_plan=v00, chain_bg=v01+. building_group secondary location은 자체 row 없음, planner.location_ids reverse lookup | outlook 패턴(C01+O01/O02)과 일관성 + DB migration 1개로 깔끔 |
| 3 | location_floor_plan ThreadPool 제거 → sequential | multi-turn 컨텍스트 보장 |
| 4 | chain_bg_planning legacy fallback 보존 | mode=chain_only 회귀 |
| 5 | chain_bg_render 병렬화 제거 | parent 의존성 sequential 강제 |
| 6 | gpt-image-2 multi-image edit (Phase 4 ExitStack 그대로) | stateless 이미지 모델의 ref 패턴 |
| 7 | semantic multi-turn (각 호출은 1회, prev 결과 user_prompt inject) | HARD 제약 충족 + 컨텍스트 보존 |
| 8 | runtime enum schema injection (scene_director 패턴) | location_id/shot_id 강제 |
| 9 | prompt v2 디렉토리 신규 (v1 보존) | 사용자의 "프롬프트 덮어쓰기 금지" 룰 준수 |
| 10 | 회귀 테스트 우선 + Phase 3+4 결과 검증 후 Phase 5 첫 commit | 안정성 |
| 11 | **Phase 5 scope = ImageAsset 영속화까지**. scene_detail이 t2i_guide를 lookup하여 shot t2i에 inject하는 것은 Phase 6 | Phase 5 단일 PR scope 폭주 방지 |

---

## 16. 위험 및 완화

| 위험 | 영향 | 완화 |
|---|---|---|
| LLM planner가 frequency rule 위반 (3+ outdoor에 floor_plan 부여) | 비용 증가 | post-validation에서 위반 시 retry + system 재강조 |
| LLM이 building_group 잘못 묶음 (관련 없는 location 동거) | 도면 quality 저하 | rationale 필수 + 사용자 비교 페이지 검토 |
| 같은 building_group의 prev PNG ref가 multi-image 토큰 폭발 | 비용 | building_group 내 max 3개 제한 (oldest 2개만) |
| chain_bg_order에 cycle / 잘못된 parent_id | 렌더 fail | post-validation: topological sort 검증 |
| sequential 처리로 인한 wallclock 증가 | UX | 평균 ~5 floor plans + ~10 chain_bg = ~30분 (Phase 3+4 동급) |

---

## 17. 다음 단계

1. **Plan 문서 작성 완료** → `docs/2026-04-29-phase5-background-planner-plan.md` (10 task TDD)
2. **사용자 review** (design + plan, ImageAsset C안 확정)
3. **승인 후 subagent-driven implementation 시작** (task별 모델 차등: haiku / sonnet / opus)
4. **Phase 5 완료 후 Phase 6**: scene_detail이 ImageAsset.t2i_guide를 shot의 location+state로 lookup → t2i_prompt에 inject

## 18. Phase 6 미리보기 (참고)

Phase 5에서 ImageAsset 영속화까지만 완료. 실제 shot 이미지 quality 향상은 Phase 6에서:

```
scene_detail._analyze_one():
  shot의 primary_location + active_state(time/event 기반) → variant_label 결정
  ImageAsset query: (project_id, asset_type='chain_bg', short_id=loc, variant_label=label)
  if found and t2i_guide:
      user_prompt += "[BACKGROUND STATE GUIDE]\n" + asset.t2i_guide + "\n\n"
  → LLM이 t2i_prompt에 background state를 명시적으로 반영
```

이는 Phase 2에서 이미 chain_bg_planning이 출력한 `shot_guides[]`의 영속화된 형태를 재사용하는 것. Phase 6는 lookup + injection만 추가하는 작은 PR.
