# Phase 3 — `location_floor_plan` Implementation Plan

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

**Goal:** `background_mode=floor_plan_anchored` 토글 시 location 단위로 top-down architectural floor plan PNG 1장을 자동 생성하는 step을 신설한다. spike test 6 패턴(gpt-5.5로 도면 prompt 자동 작성 → gpt-image-2로 PNG 생성)을 production화하고, ImageAsset 테이블에 `asset_type='floor_plan'`로 등록한다.

**Architecture:** 단일 step `location_floor_plan` (order 21.5, asset). 내부 2 phase: (A) gpt-5.5 text prompt 생성, (B) gpt-image-2 PNG 생성. toggle `settings.background_mode` Literal["off", "chain_only", "floor_plan_anchored"] = "off" default. applicability `if_floor_plan_mode` validator. 회귀 보장 3중: mode default off + spike test 6 패턴 그대로 + 신규 step 단독 (chain_bg / scene_image / scene_detail 변경 0건).

**Tech Stack:** Python 3.11, pytest, gpt-5.5 (text), gpt-image-2 (image), Pydantic v2 BaseSettings, OpenAI SDK, SQLAlchemy ORM (ImageAsset).

**Spec:** `docs/2026-04-29-phase3-location-floor-plan-design.md`

---

## File Structure

| 파일 | 책임 | 변경 종류 |
|---|---|---|
| `backend/app/core/config.py` | `background_mode` Literal 토글 정의 | 1 line 추가 |
| `backend/app/core/applicability.py` | `_if_floor_plan_mode` validator + 레지스트리 등록 | 함수 + dict entry |
| `backend/app/core/step_manifest.py` | `location_floor_plan` entry | dict entry 추가 |
| `backend/app/core/steps/__init__.py` | LocationFloorPlanStep import + STEP_CLASSES 등록 | 2 lines |
| `backend/app/core/steps/location_floor_plan_step.py` | StepRunner — 의존 로드 + 결과 집계 + DB UPSERT | **신규 파일** |
| `backend/app/modules/pipeline/location_floor_plan.py` | pure 함수 — prompt 생성 + image 호출 | **신규 파일** |
| `backend/app/modules/llm/llm_client.py` | PIPELINE_STEPS["location_floor_plan"] | dict entry 추가 |
| `prompts/_base/location_floor_plan/1.<ts>/system.md` | spike test 6의 PROMPT_GEN_SYSTEM 그대로 | **신규 파일** |
| `prompts/_base/location_floor_plan/1.<ts>/user_template.md` | location + scenes + shots placeholder | **신규 파일** |
| `backend/tests/core/test_location_floor_plan.py` | 단위 테스트 17개 | **신규 파일** |

타임스탬프 `<ts>`: 작업 시작 시 `date +%Y%m%d%H%M`로 결정. 예: `1.202604291400`.

---

## Task 1: Settings toggle `background_mode` 추가

**Files:**
- Modify: `backend/app/core/config.py:48-49` (chain_bg_guide_enabled 다음)
- Test: `backend/tests/core/test_location_floor_plan.py` (신규)

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

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

```python
"""Phase 3 — location_floor_plan step 단위 테스트."""
from __future__ import annotations

import json
from pathlib import Path
from typing import Any, Dict
from unittest.mock import MagicMock, patch

import pytest


def test_settings_background_mode_default_off():
    """background_mode default는 'off' — 회귀 0건 보장."""
    from app.core.config import settings
    assert settings.background_mode == "off"
```

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

```bash
cd /Users/manta/Documents/Projects/TheRoad-I1/backend
.venv/bin/pytest tests/core/test_location_floor_plan.py::test_settings_background_mode_default_off -v
```

Expected: FAIL — `AttributeError: 'Settings' object has no attribute 'background_mode'`

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

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

```python
    background_mode: Literal["off", "chain_only", "floor_plan_anchored"] = "off"  # Phase 3: floor plan + chain bg + scene image 모드. off=회귀 0건, chain_only=chain bg만, floor_plan_anchored=full architecture (gpt-image-2)
```

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

```bash
.venv/bin/pytest tests/core/test_location_floor_plan.py::test_settings_background_mode_default_off -v
```

Expected: PASS

- [ ] **Step 1.5: Commit**

```bash
git add backend/app/core/config.py backend/tests/core/test_location_floor_plan.py
git commit -m "$(cat <<'EOF'
feat(phase3): background_mode Literal toggle (off/chain_only/floor_plan_anchored)

Phase 3 location_floor_plan의 진입점. default off로 회귀 0건 보장.

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

---

## Task 2: Applicability validator `if_floor_plan_mode`

**Files:**
- Modify: `backend/app/core/applicability.py:99-113` (_if_shot_essence_enabled 다음)
- Test: `backend/tests/core/test_location_floor_plan.py`

- [ ] **Step 2.1: 테스트 추가 (3 cases)**

`test_location_floor_plan.py`에 추가:

```python
def test_applicability_off_returns_false(monkeypatch):
    """background_mode='off' 시 step 비활성."""
    from app.core.config import settings
    monkeypatch.setattr(settings, "background_mode", "off")
    from app.core.applicability import _if_floor_plan_mode
    runner = MagicMock()
    assert _if_floor_plan_mode(runner) is False


def test_applicability_chain_only_returns_false(monkeypatch):
    """background_mode='chain_only' 시도 floor plan은 비활성."""
    from app.core.config import settings
    monkeypatch.setattr(settings, "background_mode", "chain_only")
    from app.core.applicability import _if_floor_plan_mode
    runner = MagicMock()
    assert _if_floor_plan_mode(runner) is False


def test_applicability_floor_plan_anchored_returns_true(monkeypatch):
    """background_mode='floor_plan_anchored' 시만 활성."""
    from app.core.config import settings
    monkeypatch.setattr(settings, "background_mode", "floor_plan_anchored")
    from app.core.applicability import _if_floor_plan_mode
    runner = MagicMock()
    assert _if_floor_plan_mode(runner) is True
```

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

```bash
.venv/bin/pytest tests/core/test_location_floor_plan.py -v -k applicability
```

Expected: 3 FAIL — `ImportError` 또는 `AttributeError: ... has no attribute '_if_floor_plan_mode'`

- [ ] **Step 2.3: applicability.py에 validator 추가**

`backend/app/core/applicability.py:107` 직후 (`_if_shot_essence_enabled` 함수 다음):

```python
def _if_floor_plan_mode(runner: "StepRunner") -> bool:
    """settings.background_mode == 'floor_plan_anchored' 일 때만 실행 (Phase 3).

    'off'/'chain_only' 모드에선 step이 not_applicable로 자동 제외되어
    체크포인트 미생성 + run-all에서 skip.
    """
    from app.core.config import settings
    return settings.background_mode == "floor_plan_anchored"
```

레지스트리 (`APPLICABILITY_VALIDATORS`) 에 추가:

```python
APPLICABILITY_VALIDATORS: Dict[str, ApplicabilityValidator] = {
    "if_planning_doc": _if_planning_doc,
    "if_has_outlooks": _if_has_outlooks,
    "if_shot_essence_enabled": _if_shot_essence_enabled,
    "if_floor_plan_mode": _if_floor_plan_mode,
}
```

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

```bash
.venv/bin/pytest tests/core/test_location_floor_plan.py -v -k applicability
```

Expected: 3 PASS

- [ ] **Step 2.5: Commit**

```bash
git add backend/app/core/applicability.py backend/tests/core/test_location_floor_plan.py
git commit -m "$(cat <<'EOF'
feat(phase3): if_floor_plan_mode applicability validator

settings.background_mode == 'floor_plan_anchored' 일 때만 location_floor_plan
step 활성. 기본 off 모드에서 자동 not_applicable.

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

---

## Task 3: Prompt v1 신규 (system.md + user_template.md)

**Files:**
- Create: `prompts/_base/location_floor_plan/1.<ts>/system.md`
- Create: `prompts/_base/location_floor_plan/1.<ts>/user_template.md`
- Test: `backend/tests/core/test_location_floor_plan.py`

- [ ] **Step 3.1: 타임스탬프 결정 + 디렉토리 생성**

```bash
TS=$(date +%Y%m%d%H%M)
mkdir -p /Users/manta/Documents/Projects/TheRoad-I1/prompts/_base/location_floor_plan/1.${TS}
echo "버전 디렉토리: 1.${TS}"
```

- [ ] **Step 3.2: 테스트 추가 (prompt loading)**

`test_location_floor_plan.py`에 추가:

```python
def test_prompt_v1_loads():
    """location_floor_plan/1.<ts>/system.md 와 user_template.md 가 로드된다."""
    from app.modules.prompt_loader import load_prompt
    sys_text = load_prompt("location_floor_plan", "system", db=None)
    user_text = load_prompt("location_floor_plan", "user_template", db=None)
    # 핵심 키워드만 검증 — 정확한 본문 변경에 결합되지 않도록.
    assert "Top-down architectural floor plan" in sys_text
    assert "{location_short_id}" in user_text or "{{location_short_id}}" in user_text
    assert "{scenes_text}" in user_text or "{{scenes_text}}" in user_text
```

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

```bash
.venv/bin/pytest tests/core/test_location_floor_plan.py::test_prompt_v1_loads -v
```

Expected: FAIL — prompt 파일 미존재

- [ ] **Step 3.4: system.md 작성 (spike test 6의 PROMPT_GEN_SYSTEM 그대로)**

`prompts/_base/location_floor_plan/1.<ts>/system.md`:

````markdown
You are a film production designer. Given the screenplay scenes that occur in a single shooting LOCATION, write a precise English text-to-image prompt that will produce a TOP-DOWN ARCHITECTURAL FLOOR PLAN diagram of that location.

The output prompt will be sent directly to gpt-image-2 (text-to-image). It must produce a schematic floor plan (NOT a photorealistic interior).

REQUIREMENTS for the prompt:
1. Start with: "Top-down architectural floor plan of [location description]."
2. List ALL rooms/zones explicitly with their relative positions (corner, side, center).
3. For each room, specify: which wall has which furniture, the size of furniture relative to the room, and how furniture relates to other furniture (e.g., "TV on left wall facing sofa on right wall").
4. Specify all doors: which wall, where it leads (interior to which room? front entry to outside what?).
5. Specify windows: which wall, which room.
6. Specify external adjacency: what is OUTSIDE the front entry door (rooftop concrete? balcony? hallway? street? cite the scenario explicitly).
7. Add explicit NEGATIVE constraints — what NOT to draw (e.g., "NOT a balcony, NOT a multi-floor building, NOT a single-room studio").
8. Use universal nouns. NO scenario proper nouns (character names, place names, work titles).
9. Korean common nouns are OK in parentheses for room labels (e.g., "main bedroom (안방)").
10. End with: "Schematic line drawing style, clean labels, no shading, white background."

CRITICAL: Read EVERY scene/shot carefully. Identify zone markers (e.g., '/거실', '/안방', '/욕실', '/현관', '/욕조'). Every zone mentioned must appear in the floor plan. If a scene shows a bathroom mirror, the floor plan must include the bathroom with mirror. If a scene shows the front door opening to a rooftop, the floor plan must show ROOFTOP outside, not balcony.

OUTPUT: One single block of English prompt text only. No JSON, no explanation, no markdown headers. Length 500–6000 characters.
````

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

`prompts/_base/location_floor_plan/1.<ts>/user_template.md`:

```markdown
LOCATION ID: {location_short_id}
LOCATION LABEL: {location_label}

── ALL SCENES OCCURRING IN THIS LOCATION ──

{scenes_text}

── SELECTED SHOTS IN THIS LOCATION (visible elements) ──

{selected_shots_text}

── VISUAL WORLD RULES ──

{visual_world_rules}

Now write the floor plan text-to-image prompt for this location.
```

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

```bash
.venv/bin/pytest tests/core/test_location_floor_plan.py::test_prompt_v1_loads -v
```

Expected: PASS

- [ ] **Step 3.7: Commit**

```bash
git add prompts/_base/location_floor_plan/ backend/tests/core/test_location_floor_plan.py
git commit -m "$(cat <<'EOF'
feat(phase3): location_floor_plan prompt v1 (spike test 6 패턴)

system.md: 사용자 v3 동급 검증된 PROMPT_GEN_SYSTEM 패턴
user_template.md: location + scenes + selected shots + visual_world_rules

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

---

## Task 4: Pipeline 모듈 — `location_floor_plan.py` 신규

**Files:**
- Create: `backend/app/modules/pipeline/location_floor_plan.py`
- Test: `backend/tests/core/test_location_floor_plan.py`

- [ ] **Step 4.1: 테스트 추가 (prompt building + length validation)**

```python
def test_build_user_prompt_includes_zone_markers():
    """user_prompt에 zone marker가 포함된다."""
    from app.modules.pipeline.location_floor_plan import build_user_prompt
    template = (
        "LOCATION ID: {location_short_id}\n"
        "LOCATION LABEL: {location_label}\n\n"
        "── ALL SCENES OCCURRING IN THIS LOCATION ──\n\n"
        "{scenes_text}\n\n"
        "── SELECTED SHOTS IN THIS LOCATION (visible elements) ──\n\n"
        "{selected_shots_text}\n\n"
        "── VISUAL WORLD RULES ──\n\n"
        "{visual_world_rules}\n"
    )
    out = build_user_prompt(
        template=template,
        location_short_id="L05",
        location_label="rooftop apartment",
        scenes=[
            {"scene_index": 5, "heading": "S5/거실 - DAY", "text": "민숙은 /거실에 앉아"},
            {"scene_index": 12, "heading": "S12/안방 - NIGHT", "text": "수리영이 /안방에서"},
        ],
        selected_shots=[
            {"scene_index": 5, "shot_index": 1, "description": "민숙이 TV 앞 sofa에 앉음"},
        ],
        visual_world_rules="옥탑방 layout. 투룸 구조.",
    )
    assert "L05" in out
    assert "rooftop apartment" in out
    assert "/거실" in out
    assert "/안방" in out
    assert "Shot 1: 민숙이 TV 앞" in out
    assert "옥탑방 layout" in out


def test_build_user_prompt_excludes_unselected_shots():
    """selected_shots에 없는 shot_index는 user_prompt에 포함 안 됨."""
    from app.modules.pipeline.location_floor_plan import build_user_prompt
    template = "{selected_shots_text}"
    out = build_user_prompt(
        template=template,
        location_short_id="L05",
        location_label="x",
        scenes=[],
        selected_shots=[
            {"scene_index": 5, "shot_index": 1, "description": "shot one"},
            {"scene_index": 5, "shot_index": 3, "description": "shot three"},
        ],
        visual_world_rules="",
    )
    assert "shot one" in out
    assert "shot three" in out
    assert "shot two" not in out


def test_validate_prompt_length_too_short():
    """500자 미만 prompt는 ValueError."""
    from app.modules.pipeline.location_floor_plan import validate_prompt_length
    with pytest.raises(ValueError, match="too short"):
        validate_prompt_length("short")


def test_validate_prompt_length_too_long():
    """6000자 초과 prompt는 ValueError."""
    from app.modules.pipeline.location_floor_plan import validate_prompt_length
    with pytest.raises(ValueError, match="too long"):
        validate_prompt_length("x" * 6001)


def test_validate_prompt_length_ok():
    """500-6000자는 통과."""
    from app.modules.pipeline.location_floor_plan import validate_prompt_length
    validate_prompt_length("x" * 500)   # boundary OK
    validate_prompt_length("x" * 6000)  # boundary OK
```

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

```bash
.venv/bin/pytest tests/core/test_location_floor_plan.py -v -k "build_user_prompt or validate_prompt"
```

Expected: 5 FAIL — `ImportError` (모듈 없음)

- [ ] **Step 4.3: location_floor_plan.py 작성**

`backend/app/modules/pipeline/location_floor_plan.py`:

```python
"""location_floor_plan pipeline 모듈 — 도면 prompt 생성 + PNG 호출.

Phase 3 (v3 plan, 2026-04-29). spike test 6 패턴 production화.

순수 함수:
  - build_user_prompt: location 메타 + scenes + shots → user_prompt
  - validate_prompt_length: 500 ≤ len ≤ 6000

LLM 호출:
  - generate_floor_plan_prompt: gpt-5.5 → 영문 도면 prompt
  - generate_floor_plan_image: gpt-image-2 → PNG bytes
"""
from __future__ import annotations

import base64
import logging
import time
from typing import Any, Dict, List, Optional, Tuple

logger = logging.getLogger(__name__)

PROMPT_LEN_MIN = 500
PROMPT_LEN_MAX = 6000
LLM_RETRY_MAX = 3
LLM_RETRY_BACKOFF_STEP = 2  # linear: 2, 4, 6 sec
IMAGE_RETRY_MAX = 3
IMAGE_SIZE = "1024x1024"
IMAGE_QUALITY = "high"


def build_user_prompt(
    template: str,
    location_short_id: str,
    location_label: str,
    scenes: List[Dict[str, Any]],
    selected_shots: List[Dict[str, Any]],
    visual_world_rules: str,
) -> str:
    """user_template에 placeholder 치환."""
    scenes_lines: List[str] = []
    for sc in scenes:
        scenes_lines.append(f"### Scene {sc['scene_index']} — {sc.get('heading', '')}")
        scenes_lines.append(sc.get("text", ""))
        scenes_lines.append("")
    scenes_text = "\n".join(scenes_lines).rstrip()

    shots_lines: List[str] = []
    seen_si: set = set()
    by_scene: Dict[int, List[Dict[str, Any]]] = {}
    for sh in selected_shots:
        by_scene.setdefault(sh["scene_index"], []).append(sh)
    for si in sorted(by_scene):
        shots_lines.append(f"### Scene {si} selected shots:")
        for sh in by_scene[si]:
            shots_lines.append(f"- Shot {sh['shot_index']}: {sh.get('description', '')}")
        shots_lines.append("")
    selected_shots_text = "\n".join(shots_lines).rstrip()

    return template.format(
        location_short_id=location_short_id,
        location_label=location_label,
        scenes_text=scenes_text,
        selected_shots_text=selected_shots_text,
        visual_world_rules=(visual_world_rules or "").strip(),
    )


def validate_prompt_length(text: str) -> None:
    """gpt-image-2 prompt 길이 검증. 500 ≤ len ≤ 6000."""
    n = len(text)
    if n < PROMPT_LEN_MIN:
        raise ValueError(f"floor plan prompt too short: {n} < {PROMPT_LEN_MIN}")
    if n > PROMPT_LEN_MAX:
        raise ValueError(f"floor plan prompt too long: {n} > {PROMPT_LEN_MAX}")


def generate_floor_plan_prompt(
    *,
    system_prompt: str,
    user_prompt: str,
    project_config: Any,
    opik_metadata: Optional[Dict[str, Any]] = None,
    call_text_fn=None,
) -> str:
    """gpt-5.5에 도면 prompt 자동 작성 요청. retry + length 검증.

    call_text_fn: 테스트용 주입. None이면 app.modules.llm.llm_client.call_text 사용.
    """
    if call_text_fn is None:
        from app.modules.llm.llm_client import call_text
        call_text_fn = call_text

    last_exc: Optional[Exception] = None
    for attempt in range(LLM_RETRY_MAX + 1):
        try:
            text = call_text_fn(
                step="location_floor_plan",
                system_prompt=system_prompt,
                user_prompt=user_prompt,
                project_config=project_config,
                opik_metadata=opik_metadata or {},
            )
            text = (text or "").strip()
            validate_prompt_length(text)
            return text
        except Exception as exc:
            last_exc = exc
            if attempt < LLM_RETRY_MAX:
                delay = LLM_RETRY_BACKOFF_STEP * (attempt + 1)
                logger.warning(
                    "generate_floor_plan_prompt retry %d/%d (sleep %ds): %s",
                    attempt + 1, LLM_RETRY_MAX, delay, exc,
                )
                time.sleep(delay)
                continue
            break

    raise RuntimeError(
        f"generate_floor_plan_prompt failed after {LLM_RETRY_MAX} retries: {last_exc}"
    )


def generate_floor_plan_image(
    *,
    prompt: str,
    openai_client: Any,
    model: str = "gpt-image-2",
    size: str = IMAGE_SIZE,
    quality: str = IMAGE_QUALITY,
) -> bytes:
    """gpt-image-2 호출 → PNG bytes 반환. retry 3회."""
    last_exc: Optional[Exception] = None
    for attempt in range(IMAGE_RETRY_MAX + 1):
        try:
            resp = openai_client.images.generate(
                model=model,
                prompt=prompt,
                size=size,
                quality=quality,
                n=1,
            )
            return base64.b64decode(resp.data[0].b64_json)
        except Exception as exc:
            last_exc = exc
            if attempt < IMAGE_RETRY_MAX:
                delay = LLM_RETRY_BACKOFF_STEP * (attempt + 1)
                logger.warning(
                    "generate_floor_plan_image retry %d/%d (sleep %ds): %s",
                    attempt + 1, IMAGE_RETRY_MAX, delay, exc,
                )
                time.sleep(delay)
                continue
            break

    raise RuntimeError(
        f"generate_floor_plan_image failed after {IMAGE_RETRY_MAX} retries: {last_exc}"
    )
```

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

```bash
.venv/bin/pytest tests/core/test_location_floor_plan.py -v -k "build_user_prompt or validate_prompt"
```

Expected: 5 PASS

- [ ] **Step 4.5: retry 테스트 추가 + 통과 확인**

```python
def test_generate_prompt_retry_on_failure(monkeypatch):
    """gpt-5.5 호출 실패 시 retry 3회 + 최종 실패는 RuntimeError."""
    from app.modules.pipeline.location_floor_plan import generate_floor_plan_prompt

    calls = {"count": 0}
    def failing(**kwargs):
        calls["count"] += 1
        raise RuntimeError("LLM down")

    monkeypatch.setattr("app.modules.pipeline.location_floor_plan.time.sleep", lambda s: None)
    with pytest.raises(RuntimeError, match="failed after 3 retries"):
        generate_floor_plan_prompt(
            system_prompt="x",
            user_prompt="y",
            project_config=MagicMock(),
            call_text_fn=failing,
        )
    assert calls["count"] == 4  # 1 initial + 3 retries


def test_generate_prompt_succeeds_on_retry(monkeypatch):
    """첫 호출 실패 → 두 번째 성공."""
    from app.modules.pipeline.location_floor_plan import generate_floor_plan_prompt

    calls = {"count": 0}
    def flaky(**kwargs):
        calls["count"] += 1
        if calls["count"] == 1:
            raise RuntimeError("transient")
        return "x" * 1500

    monkeypatch.setattr("app.modules.pipeline.location_floor_plan.time.sleep", lambda s: None)
    out = generate_floor_plan_prompt(
        system_prompt="x",
        user_prompt="y",
        project_config=MagicMock(),
        call_text_fn=flaky,
    )
    assert len(out) == 1500
    assert calls["count"] == 2


def test_generate_image_retry_on_failure(monkeypatch):
    """gpt-image-2 호출 실패 시 retry 3회."""
    from app.modules.pipeline.location_floor_plan import generate_floor_plan_image

    client = MagicMock()
    client.images.generate.side_effect = RuntimeError("API down")
    monkeypatch.setattr("app.modules.pipeline.location_floor_plan.time.sleep", lambda s: None)
    with pytest.raises(RuntimeError, match="failed after 3 retries"):
        generate_floor_plan_image(prompt="x" * 1500, openai_client=client)
    assert client.images.generate.call_count == 4
```

```bash
.venv/bin/pytest tests/core/test_location_floor_plan.py -v -k "retry or succeeds_on"
```

Expected: 3 PASS

- [ ] **Step 4.6: Commit**

```bash
git add backend/app/modules/pipeline/location_floor_plan.py backend/tests/core/test_location_floor_plan.py
git commit -m "$(cat <<'EOF'
feat(phase3): location_floor_plan pipeline module (prompt + image gen)

build_user_prompt: location + scenes + shots + rules → user_prompt
generate_floor_plan_prompt: gpt-5.5, retry 3, length 500-6000 검증
generate_floor_plan_image: gpt-image-2, retry 3, 1024x1024 quality=high

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

---

## Task 5: StepRunner — `LocationFloorPlanStep`

**Files:**
- Create: `backend/app/core/steps/location_floor_plan_step.py`
- Test: `backend/tests/core/test_location_floor_plan.py`

- [ ] **Step 5.1: 테스트 추가 (location 그루핑 + 결과 집계)**

```python
def test_load_location_groups_filters_by_selection(tmp_path, monkeypatch):
    """selected shot이 있는 location만 그룹에 포함."""
    from app.core.steps.location_floor_plan_step import LocationFloorPlanStep

    # 가짜 체크포인트 디렉토리
    ckpt_root = tmp_path / "checkpoints" / "episodes" / "eid"
    (ckpt_root / "shot_validator").mkdir(parents=True)
    (ckpt_root / "shot_selection").mkdir(parents=True)
    (ckpt_root / "scene_save").mkdir(parents=True)

    (ckpt_root / "shot_validator" / "manifest.json").write_text(json.dumps({
        "data": {"scenes": [
            {"scene_index": 5, "shots": [
                {"shot_index": 1, "description": "민숙이 sofa에 앉음", "location_id": "L05"},
                {"shot_index": 2, "description": "수리영이 등장", "location_id": "L05"},
            ]},
            {"scene_index": 7, "shots": [
                {"shot_index": 1, "description": "외부 상점", "location_id": "L02"},
            ]},
        ]},
    }))
    (ckpt_root / "shot_selection" / "manifest.json").write_text(json.dumps({
        "data": {"scenes": [
            {"scene_index": 5, "selected_shot_indices": [1]},
            # scene 7은 shot_selection에 없음
        ]},
    }))
    (ckpt_root / "scene_save" / "manifest.json").write_text(json.dumps({
        "data": {"segments": [
            {"scene_index": 5, "heading": "S5", "text": "거실 씬"},
            {"scene_index": 7, "heading": "S7", "text": "마트 씬"},
        ]},
    }))

    monkeypatch.setattr("app.core.config.settings.projects_dir", str(tmp_path))

    runner = LocationFloorPlanStep.__new__(LocationFloorPlanStep)
    runner.project_id = ""
    runner.episode_id = "eid"

    groups = runner._load_location_groups(
        location_canon_by_short={"L05": "canon-l05", "L02": "canon-l02"},
        location_label_by_short={"L05": "rooftop", "L02": "store"},
    )
    # L05만 (selected shot 있음). L02는 shot_selection에서 제외됨.
    assert set(groups.keys()) == {"L05"}
    assert groups["L05"]["label"] == "rooftop"
    assert len(groups["L05"]["selected_shots"]) == 1
    assert groups["L05"]["selected_shots"][0]["shot_index"] == 1


def test_step_no_selected_shots_returns_zero(tmp_path, monkeypatch):
    """selected shot이 모든 location에서 0이면 applicable=0 반환."""
    from app.core.steps.location_floor_plan_step import LocationFloorPlanStep

    ckpt_root = tmp_path / "checkpoints" / "episodes" / "eid"
    (ckpt_root / "shot_validator").mkdir(parents=True)
    (ckpt_root / "shot_selection").mkdir(parents=True)
    (ckpt_root / "shot_validator" / "manifest.json").write_text(json.dumps({"data": {"scenes": []}}))
    (ckpt_root / "shot_selection" / "manifest.json").write_text(json.dumps({"data": {"scenes": []}}))
    (ckpt_root / "scene_save").mkdir(parents=True)
    (ckpt_root / "scene_save" / "manifest.json").write_text(json.dumps({"data": {"segments": []}}))

    monkeypatch.setattr("app.core.config.settings.projects_dir", str(tmp_path))
    monkeypatch.setattr("app.core.config.settings.openai_api_key", "test-key")

    runner = LocationFloorPlanStep.__new__(LocationFloorPlanStep)
    runner.project_id = ""
    runner.episode_id = "eid"
    runner.db = MagicMock()
    runner.db.query.return_value.filter.return_value.all.return_value = []
    runner.project_config = MagicMock()
    runner.build_opik_metadata = MagicMock(return_value={})

    result = runner._execute(mode="resume")
    assert result["applicable_count"] == 0
    assert result["completed_count"] == 0
    assert result["failed_count"] == 0
```

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

```bash
.venv/bin/pytest tests/core/test_location_floor_plan.py -v -k "load_location_groups or no_selected_shots"
```

Expected: 2 FAIL — `ImportError`

- [ ] **Step 5.3: location_floor_plan_step.py 작성**

`backend/app/core/steps/location_floor_plan_step.py`:

```python
"""LocationFloorPlanStep — location 단위 도면 PNG 자동 생성.

Phase 3 (v3 plan 2026-04-29). spike test 6 패턴 production화.

설계:
  - 입력: shot_validator + shot_selection + scene_save + entity_merge + visual_world_rules
  - 처리: 각 location 병렬 (max_workers=4) — gpt-5.5 prompt + gpt-image-2 PNG
  - 출력: PNG 파일 + ImageAsset(asset_type='floor_plan', is_primary=1) UPSERT
  - 토글: settings.background_mode == 'floor_plan_anchored' (applicability='if_floor_plan_mode')

체크포인트:
  data.locations[]: {id, label, scene_indices, prompt_text, prompt_chars,
                     image_path, image_bytes, gen_model, gen_size, gen_quality,
                     status, failure_reason}
  data.applicable_count / succeeded_count / failed_count
  data.schema_version: 1
  data.config_hash: ...
"""
from __future__ import annotations

import hashlib
import json
import logging
import os
import uuid
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple

from app.core.step_runner import StepRunner

logger = logging.getLogger(__name__)

SCHEMA_VERSION = 1
MAX_WORKERS = 4


class LocationFloorPlanStep(StepRunner):
    """location 단위 도면 PNG 생성 + DB 등록."""

    def _load_prev_checkpoint(self, step_id: str) -> Optional[Dict[str, Any]]:
        from app.core.config import settings
        cp = (
            Path(settings.projects_dir) / self.project_id
            / "checkpoints" / "episodes" / self.episode_id
            / step_id / "manifest.json"
        )
        if cp.exists():
            return json.loads(cp.read_text(encoding="utf-8"))
        return None

    def _execute(self, mode: str = "resume") -> Dict[str, Any]:
        from app.core.errors import AppError
        from app.core.config import settings

        if not settings.openai_api_key:
            raise AppError(
                code="step.no_openai_key",
                message="OPENAI_API_KEY 미설정 — gpt-5.5 + gpt-image-2 호출 불가.",
                status_code=400,
            )

        # location entity_canon 로드
        location_canon_by_short, location_label_by_short = self._load_location_canons()
        if not location_canon_by_short:
            logger.warning("location_floor_plan: location EntityCanon 0개 — skip")
            return self._empty_result()

        # location별 scenes/shots 그루핑
        groups = self._load_location_groups(
            location_canon_by_short, location_label_by_short,
        )
        if not groups:
            logger.warning("location_floor_plan: selected shot이 있는 location 0개 — skip")
            return self._empty_result()

        # 시각적 세계관 규칙 (선택)
        rules_cp = self._load_prev_checkpoint("visual_world_rules")
        rules_text = ""
        if rules_cp:
            data = rules_cp.get("data", {})
            rules_text = data.get("rules_text", "") or data.get("text", "") or ""

        # prompt 로드
        from app.modules.prompt_loader import load_prompt
        system_prompt = load_prompt("location_floor_plan", "system", db=self.db)
        user_template = load_prompt("location_floor_plan", "user_template", db=self.db)

        # OpenAI client
        from openai import OpenAI
        client = OpenAI(api_key=settings.openai_api_key)

        image_dir = (
            Path(settings.projects_dir) / self.project_id
            / "images" / self.episode_id / "floor_plan"
        )
        image_dir.mkdir(parents=True, exist_ok=True)

        # 병렬 처리
        results: List[Dict[str, Any]] = []
        with ThreadPoolExecutor(max_workers=MAX_WORKERS) as pool:
            futs = {
                pool.submit(
                    self._process_location,
                    short_id=short,
                    group=group,
                    system_prompt=system_prompt,
                    user_template=user_template,
                    rules_text=rules_text,
                    openai_client=client,
                    image_dir=image_dir,
                ): short
                for short, group in groups.items()
            }
            for fut in as_completed(futs):
                short = futs[fut]
                try:
                    results.append(fut.result())
                except Exception as exc:
                    logger.error("location_floor_plan: %s unexpected: %s", short, exc)
                    results.append({
                        "id": short,
                        "label": location_label_by_short.get(short, ""),
                        "scene_indices": sorted(groups[short]["scene_indices"]),
                        "prompt_text": "",
                        "prompt_chars": 0,
                        "image_path": "",
                        "image_bytes": 0,
                        "gen_model": "gpt-image-2",
                        "gen_size": "1024x1024",
                        "gen_quality": "high",
                        "status": "failed",
                        "failure_reason": f"{type(exc).__name__}: {exc}",
                    })

        # id 알파벳순 정렬 (deterministic)
        results.sort(key=lambda r: r["id"])

        succeeded = sum(1 for r in results if r["status"] == "ok")
        failed = sum(1 for r in results if r["status"] == "failed")
        applicable = len(results)

        # DB UPSERT (best-effort)
        try:
            self._register_image_assets(results, location_canon_by_short)
        except Exception as exc:
            logger.error("location_floor_plan: DB sync failed (files on disk): %s", exc)
            try:
                self.db.rollback()
            except Exception:
                pass

        return {
            "completed_count": succeeded,
            "applicable_count": applicable,
            "failed_count": failed,
            "data": {
                "schema_version": SCHEMA_VERSION,
                "config_hash": self._config_hash(),
                "locations": results,
                "applicable_count": applicable,
                "succeeded_count": succeeded,
                "failed_count": failed,
            },
        }

    # ── helpers ─────────────────────────────────────────────────────

    def _empty_result(self) -> Dict[str, Any]:
        return {
            "completed_count": 0,
            "applicable_count": 0,
            "failed_count": 0,
            "data": {
                "schema_version": SCHEMA_VERSION,
                "config_hash": self._config_hash(),
                "locations": [],
                "applicable_count": 0,
                "succeeded_count": 0,
                "failed_count": 0,
            },
        }

    def _config_hash(self) -> str:
        from app.core.config import settings
        payload = {
            "background_mode": settings.background_mode,
            "model": "gpt-image-2",
            "size": "1024x1024",
            "quality": "high",
        }
        return hashlib.sha256(
            json.dumps(payload, sort_keys=True).encode("utf-8")
        ).hexdigest()[:16]

    def _load_location_canons(self) -> Tuple[Dict[str, str], Dict[str, str]]:
        """EntityCanon에서 location 매핑. {short_id: canon_id}, {short_id: label}."""
        from app.models.project import EntityCanon

        canons = (
            self.db.query(EntityCanon)
            .filter(
                EntityCanon.project_id == self.project_id,
                EntityCanon.entity_type == "location",
            )
            .all()
        )
        canon_by_short: Dict[str, str] = {}
        label_by_short: Dict[str, str] = {}
        for c in canons:
            if c.short_id:
                canon_by_short[c.short_id] = c.id
                label_by_short[c.short_id] = c.canonical_name or c.short_id
        return canon_by_short, label_by_short

    def _load_location_groups(
        self,
        location_canon_by_short: Dict[str, str],
        location_label_by_short: Dict[str, str],
    ) -> Dict[str, Dict[str, Any]]:
        """location별 selected shot + 관련 scene 그루핑.

        반환: {short_id: {label, scene_indices: set, scenes: [...], selected_shots: [...]}}
        """
        sv_cp = self._load_prev_checkpoint("shot_validator")
        ss_cp = self._load_prev_checkpoint("shot_selection")
        scene_cp = self._load_prev_checkpoint("scene_save")
        if not sv_cp or not ss_cp or not scene_cp:
            return {}

        sel_map: Dict[int, set] = {}
        for s in ss_cp.get("data", {}).get("scenes", []) or []:
            sel_map[s["scene_index"]] = set(s.get("selected_shot_indices", []))

        seg_by_index: Dict[int, Dict[str, Any]] = {
            seg["scene_index"]: seg
            for seg in scene_cp.get("data", {}).get("segments", []) or []
        }

        # location별 dict 빌드
        groups: Dict[str, Dict[str, Any]] = {}
        for s in sv_cp.get("data", {}).get("scenes", []) or []:
            si = s["scene_index"]
            if si not in sel_map:
                continue
            sel = sel_map[si]
            for sh in s.get("shots", []) or []:
                shi = sh.get("shot_index", 0)
                if shi not in sel:
                    continue
                loc_short = sh.get("location_id") or ""
                if not loc_short or loc_short not in location_canon_by_short:
                    continue
                g = groups.setdefault(loc_short, {
                    "label": location_label_by_short.get(loc_short, loc_short),
                    "scene_indices": set(),
                    "scenes": [],
                    "selected_shots": [],
                })
                g["scene_indices"].add(si)
                g["selected_shots"].append({
                    "scene_index": si,
                    "shot_index": shi,
                    "description": sh.get("description", "") or "",
                })

        # scenes 채우기
        for short, g in groups.items():
            for si in sorted(g["scene_indices"]):
                seg = seg_by_index.get(si)
                if seg:
                    g["scenes"].append({
                        "scene_index": si,
                        "heading": seg.get("heading", ""),
                        "text": seg.get("text", "") or "",
                    })
        return groups

    def _process_location(
        self,
        *,
        short_id: str,
        group: Dict[str, Any],
        system_prompt: str,
        user_template: str,
        rules_text: str,
        openai_client: Any,
        image_dir: Path,
    ) -> Dict[str, Any]:
        from app.modules.pipeline.location_floor_plan import (
            build_user_prompt,
            generate_floor_plan_prompt,
            generate_floor_plan_image,
        )

        result: Dict[str, Any] = {
            "id": short_id,
            "label": group["label"],
            "scene_indices": sorted(group["scene_indices"]),
            "prompt_text": "",
            "prompt_chars": 0,
            "image_path": "",
            "image_bytes": 0,
            "gen_model": "gpt-image-2",
            "gen_size": "1024x1024",
            "gen_quality": "high",
            "status": "failed",
            "failure_reason": None,
        }

        try:
            user_prompt = build_user_prompt(
                template=user_template,
                location_short_id=short_id,
                location_label=group["label"],
                scenes=group["scenes"],
                selected_shots=group["selected_shots"],
                visual_world_rules=rules_text,
            )
            prompt_text = generate_floor_plan_prompt(
                system_prompt=system_prompt,
                user_prompt=user_prompt,
                project_config=self.project_config,
                opik_metadata=self.build_opik_metadata(),
            )
            png_bytes = generate_floor_plan_image(
                prompt=prompt_text,
                openai_client=openai_client,
            )

            png_path = image_dir / f"{short_id}.png"
            png_path.write_bytes(png_bytes)

            from app.core.config import settings
            rel_path = str(png_path.relative_to(Path(settings.projects_dir).parent))

            result["prompt_text"] = prompt_text
            result["prompt_chars"] = len(prompt_text)
            result["image_path"] = rel_path
            result["image_bytes"] = len(png_bytes)
            result["status"] = "ok"
            logger.info(
                "location_floor_plan: %s ok (prompt=%d chars, png=%d bytes)",
                short_id, len(prompt_text), len(png_bytes),
            )
        except Exception as exc:
            logger.warning("location_floor_plan: %s failed: %s", short_id, exc)
            result["failure_reason"] = f"{type(exc).__name__}: {exc}"

        return result

    def _register_image_assets(
        self,
        locations: List[Dict[str, Any]],
        location_canon_by_short: Dict[str, str],
    ) -> None:
        """ImageAsset(asset_type='floor_plan', is_primary=1) UPSERT."""
        from app.models.project import ImageAsset

        now = datetime.now(timezone.utc).isoformat()
        registered = 0
        for loc in locations:
            if loc["status"] != "ok" or not loc["image_path"]:
                continue
            short = loc["id"]
            canon_id = location_canon_by_short.get(short)
            if not canon_id:
                continue
            existing = (
                self.db.query(ImageAsset)
                .filter(
                    ImageAsset.project_id == self.project_id,
                    ImageAsset.entity_id == canon_id,
                    ImageAsset.episode_id == self.episode_id,
                    ImageAsset.asset_type == "floor_plan",
                )
                .first()
            )
            if existing:
                existing.file_path = loc["image_path"]
                existing.prompt_used = loc["prompt_text"]
                existing.status = "generated"
                existing.is_primary = 1
            else:
                self.db.add(ImageAsset(
                    id=str(uuid.uuid4()),
                    project_id=self.project_id,
                    asset_type="floor_plan",
                    entity_id=canon_id,
                    episode_id=self.episode_id,
                    file_path=loc["image_path"],
                    prompt_used=loc["prompt_text"],
                    generation_model="gpt-image-2",
                    status="generated",
                    variant_type=None,
                    is_primary=1,
                    created_at=now,
                ))
            registered += 1

        if registered:
            self.db.commit()
            logger.info("location_floor_plan: registered %d ImageAssets", registered)
```

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

```bash
.venv/bin/pytest tests/core/test_location_floor_plan.py -v -k "load_location_groups or no_selected_shots"
```

Expected: 2 PASS

- [ ] **Step 5.5: 추가 테스트 (config_hash + failed status)**

```python
def test_config_hash_changes_on_mode_flip(monkeypatch):
    """background_mode 변경 시 config_hash가 달라진다."""
    from app.core.steps.location_floor_plan_step import LocationFloorPlanStep
    from app.core.config import settings

    runner = LocationFloorPlanStep.__new__(LocationFloorPlanStep)

    monkeypatch.setattr(settings, "background_mode", "off")
    h1 = runner._config_hash()
    monkeypatch.setattr(settings, "background_mode", "floor_plan_anchored")
    h2 = runner._config_hash()
    assert h1 != h2


def test_process_location_failed_records_status(tmp_path, monkeypatch):
    """LLM 실패 시 status='failed' + failure_reason 기록."""
    from app.core.steps.location_floor_plan_step import LocationFloorPlanStep

    runner = LocationFloorPlanStep.__new__(LocationFloorPlanStep)
    runner.project_config = MagicMock()
    runner.build_opik_metadata = MagicMock(return_value={})

    monkeypatch.setattr(
        "app.modules.pipeline.location_floor_plan.generate_floor_plan_prompt",
        lambda **kw: (_ for _ in ()).throw(RuntimeError("LLM down")),
    )
    image_dir = tmp_path / "fp"
    image_dir.mkdir()
    result = runner._process_location(
        short_id="L05",
        group={"label": "rooftop", "scene_indices": {5}, "scenes": [], "selected_shots": []},
        system_prompt="x", user_template="{location_short_id}",
        rules_text="", openai_client=MagicMock(), image_dir=image_dir,
    )
    assert result["status"] == "failed"
    assert "RuntimeError" in result["failure_reason"]
    assert result["image_path"] == ""
```

```bash
.venv/bin/pytest tests/core/test_location_floor_plan.py -v -k "config_hash or process_location_failed"
```

Expected: 2 PASS

- [ ] **Step 5.6: Commit**

```bash
git add backend/app/core/steps/location_floor_plan_step.py backend/tests/core/test_location_floor_plan.py
git commit -m "$(cat <<'EOF'
feat(phase3): LocationFloorPlanStep — location별 도면 PNG + DB UPSERT

- _load_location_canons: EntityCanon → short_id 매핑
- _load_location_groups: selected shot 있는 location만 그루핑
- _process_location: prompt → PNG → 파일 저장
- _register_image_assets: ImageAsset(asset_type='floor_plan') UPSERT
- _config_hash: background_mode/모델/사이즈 sha256[:16]

병렬도 4, 실패 시 status='failed' + failure_reason 기록.

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

---

## Task 6: Manifest + STEP_CLASSES + PIPELINE_STEPS 등록

**Files:**
- Modify: `backend/app/core/step_manifest.py`
- Modify: `backend/app/core/steps/__init__.py`
- Modify: `backend/app/modules/llm/llm_client.py`
- Test: `backend/tests/core/test_location_floor_plan.py` + 기존 manifest 테스트

- [ ] **Step 6.1: 테스트 추가 (manifest entry + 의존)**

```python
def test_manifest_entry_exists():
    """step_manifest에 location_floor_plan 등록됨."""
    from app.core.step_manifest import STEP_MANIFEST
    assert "location_floor_plan" in STEP_MANIFEST
    e = STEP_MANIFEST["location_floor_plan"]
    assert e["category"] == "image"
    assert e["order"] == 21.5
    assert e["applicability"] == "if_floor_plan_mode"
    assert e["step_type"] == "asset"
    assert e["lifecycle"] == "active"
    assert "shot_validator" in e["depends_on"]
    assert "shot_selection" in e["depends_on"]
    assert "scene_director" in e["depends_on"]


def test_step_classes_includes_location_floor_plan():
    """STEP_CLASSES에 LocationFloorPlanStep 등록됨."""
    from app.core.steps import STEP_CLASSES
    from app.core.steps.location_floor_plan_step import LocationFloorPlanStep
    assert STEP_CLASSES.get("location_floor_plan") is LocationFloorPlanStep


def test_pipeline_steps_includes_location_floor_plan():
    """llm_client.PIPELINE_STEPS 등록 — 모델 매핑 parity."""
    from app.modules.llm.llm_client import PIPELINE_STEPS
    assert "location_floor_plan" in PIPELINE_STEPS
    assert PIPELINE_STEPS["location_floor_plan"]["default"] in ("gpt", "gpt-5.5")
```

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

```bash
.venv/bin/pytest tests/core/test_location_floor_plan.py -v -k "manifest_entry or step_classes_includes or pipeline_steps_includes"
```

Expected: 3 FAIL

- [ ] **Step 6.3: step_manifest.py에 entry 추가**

`backend/app/core/step_manifest.py`의 `shot_essence_extraction` entry 다음 (line 552 근처) 또는 image phase 시작 직전 적절한 위치에 추가:

```python
    # 21.5 location_floor_plan — Phase 3 (v3 plan, 2026-04-29).
    # location 단위 top-down architectural floor plan PNG 1장 자동 생성.
    # gpt-5.5 (prompt 자동 작성) + gpt-image-2 (PNG 생성).
    # toggle: settings.background_mode == 'floor_plan_anchored'
    #   → applicability='if_floor_plan_mode'. off/chain_only 모드에선 자동 not_applicable.
    # spike test 6 (backend/scripts/spike_floor_plan_svg/test_06_auto_floor_plan.py)
    # 의 패턴 그대로 production화. 사람 개입 0.
    "location_floor_plan": {
        "label": "위치 도면 생성",
        "category": "image",
        "order": 21.5,  # 21.5: scene_verify(21) 후 / world_guide(22) 전. image>analysis 불변식 만족.
        "default_model": "gpt",
        "provider": "openai",
        "depends_on": [
            "shot_validator", "shot_selection",
            "scene_save", "entity_merge",
            "visual_world_rules", "scene_director",
        ],
        "fan_out": False,
        "applicability": "if_floor_plan_mode",
        "step_type": "asset",
        "lifecycle": "active",
    },
```

- [ ] **Step 6.4: __init__.py 등록**

`backend/app/core/steps/__init__.py`에 추가:

```python
from app.core.steps.location_floor_plan_step import LocationFloorPlanStep

# STEP_CLASSES 딕셔너리에 등록
STEP_CLASSES["location_floor_plan"] = LocationFloorPlanStep
```

- [ ] **Step 6.5: llm_client.py PIPELINE_STEPS 등록**

`backend/app/modules/llm/llm_client.py`의 `PIPELINE_STEPS`에 추가:

```python
"location_floor_plan": {
    "label": "위치 도면 생성",
    "default": "gpt",
    "category": "image",
},
```

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

```bash
.venv/bin/pytest tests/core/test_location_floor_plan.py -v
```

Expected: 모든 신규 테스트 PASS

- [ ] **Step 6.7: 회귀 테스트 (기존 manifest/catalog 테스트)**

```bash
.venv/bin/pytest tests/core/test_step_manifest_v3.py tests/core/test_manifest_fields.py tests/core/test_step_catalog.py -v
```

Expected: 모두 PASS (location_floor_plan 추가로 카운트 +1).

기존 기대 카운트가 hard-coded인 경우 같은 commit에서 보수적으로 조정. (manifest 카운트 명시 테스트는 v3 plan 작성 시 인지하고 만든 것이라 안전 — 신규 step도 동일 필드 검증 통과).

- [ ] **Step 6.8: Commit**

```bash
git add backend/app/core/step_manifest.py backend/app/core/steps/__init__.py backend/app/modules/llm/llm_client.py backend/tests/core/test_location_floor_plan.py
git commit -m "$(cat <<'EOF'
feat(phase3): location_floor_plan manifest + STEP_CLASSES + PIPELINE_STEPS 등록

- step_manifest: order 21.5, category=image, asset, if_floor_plan_mode
- STEP_CLASSES["location_floor_plan"] = LocationFloorPlanStep
- llm_client.PIPELINE_STEPS: gpt default

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

---

## Task 7: 통합 회귀 테스트

**Files:**
- 모든 기존 테스트가 회귀 0 인지 확인.

- [ ] **Step 7.1: 전체 core 테스트 실행**

```bash
cd /Users/manta/Documents/Projects/TheRoad-I1/backend
.venv/bin/pytest tests/core tests/pipeline -v --tb=short 2>&1 | tail -50
```

Expected:
- 신규: test_location_floor_plan.py — 17개 모두 PASS
- 회귀: test_chain_bg_guide.py (19), test_shot_essence_extraction.py (10), test_step_manifest_v3.py (manifest count 갱신됨), test_manifest_fields.py (18), test_step_catalog.py (count 갱신됨), test_background_chain_render.py (17) 모두 PASS

총: ~117 PASSED 회귀 0 목표. 만약 카운트 hard-code 테스트가 깨지면 같은 commit에서 +1 조정.

- [ ] **Step 7.2: linter / type 검사 (있으면)**

```bash
cd /Users/manta/Documents/Projects/TheRoad-I1/backend
.venv/bin/python -m py_compile \
    app/core/config.py \
    app/core/applicability.py \
    app/core/step_manifest.py \
    app/core/steps/__init__.py \
    app/core/steps/location_floor_plan_step.py \
    app/modules/pipeline/location_floor_plan.py \
    app/modules/llm/llm_client.py
```

Expected: no output (compile OK).

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

CLAUDE.md `feedback_dual_code_review` 규칙. 컨트롤러가 두 reviewer subagent를 dispatch:
- Claude code-reviewer: 보안, 로직, edge cases
- Codex (가능하면): 회귀 가드, 의존성, manifest parity

Phase 2 회고에서 본 환경의 Codex가 30분+ 소요 비현실적 — 두 가지 옵션:
- (A) Codex `--effort minimal` + 짧은 prompt (git diff만)로 시도
- (B) Claude만 + 명시적 사용자 승인 (Phase 2 패턴)

- [ ] **Step 7.4: 리뷰 피드백 반영 commit**

리뷰 결과의 Important 이상은 fix. Minor는 백로그.

---

## Task 8: 메모리 + 푸시

**Files:**
- Update memory: `session_20260429_phase3.md` + `MEMORY.md`

- [ ] **Step 8.1: Phase 3 세션 메모리 작성**

`/Users/manta/.claude/projects/-Users-manta-Documents-Projects-TheRoad-I1/memory/session_20260429_phase3.md`:

```markdown
---
name: 2026-04-29 세션 — Phase 3 location_floor_plan step 신설
description: gpt-5.5 + gpt-image-2 도면 PNG 자동 생성 step. spike test 6 패턴 production화. 토글 background_mode default off.
type: project
---

# 세션 핵심 결과

Phase 3 완료. location_floor_plan step 신설로 location 단위 top-down floor plan PNG 자동 생성.

## Commits (예상)
1. feat(phase3): background_mode toggle
2. feat(phase3): if_floor_plan_mode applicability
3. feat(phase3): prompt v1 (spike test 6 패턴)
4. feat(phase3): pipeline 모듈 (prompt + image gen)
5. feat(phase3): LocationFloorPlanStep
6. feat(phase3): manifest + STEP_CLASSES + PIPELINE_STEPS
7. fix(phase3): 리뷰 피드백 (있을 시)

(N files +XXX/-Y, ~117 PASSED 회귀 0)

## 핵심 설계
- single step, 2 phase 내부 (gpt-5.5 prompt → gpt-image-2 PNG)
- 사람 개입 0 (multi-candidate / vision validator / edit UI 없음)
- ImageAsset(asset_type='floor_plan', is_primary=1) UPSERT
- toggle background_mode default off → 회귀 0건

## 다음 Phase
- Phase 4: chain_bg_render 모델 분기 + 도면 PNG ref input
- Phase 5: scene_image_pipeline mode 분기 + 도면 ref input
- Phase 6: E2E 5 시나리오 + baseline 비교
```

- [ ] **Step 8.2: MEMORY.md 인덱스 추가**

`MEMORY.md`에 1줄 추가:

```markdown
- [session_20260429_phase3.md](session_20260429_phase3.md) - Phase 3 location_floor_plan step 완성. background_mode default off, gpt-5.5 + gpt-image-2, spike test 6 패턴, ImageAsset asset_type='floor_plan'
```

- [ ] **Step 8.3: 사용자 명시 승인 후 push**

```bash
git push origin main
```

⚠️ 사용자 명시 승인 전에는 push 금지 (CLAUDE.md feedback_review_before_commit + 일반 안전 규칙).

---

## 진행 위치 (v3 plan section 11 기준)

| Day | Phase | 상태 |
|---|---|---|
| 1 | P0 | ✅ commit aacccd9 |
| 2 | P0 + 1a | ⚠️ 1a 보류 |
| 3 | 1b | ✅ commit 553fea2 |
| 4 | 2 | ✅ commit 04840cc |
| **5~6** | **3** | **🔵 본 plan** |
| 7 | 4 | 대기 |
| 8 | 5 | 대기 |
| 9~10 | 6 | 대기 |

총 6~10일 일정 중 본 plan 완료 시 5/8 (P0 + 1b + 2 + 3).

---

## 자기 검토 (self-review checklist)

- [ ] Spec coverage: design doc 16 섹션이 모두 task로 커버됨?
- [ ] No placeholders: TBD/TODO/“implement later” 등 없음?
- [ ] Type consistency: 함수/필드 이름이 task 간 일관됨?
- [ ] Dual review 명시: Task 7.3에 명시됨?
- [ ] 회귀 가드 3중 명시: mode default off + spike 패턴 + step 단독?
- [ ] 누락 요건: 없음 (16/16 design 섹션 cover).

문제 발견 시 inline 수정 후 다음 단계.
