### Task 7: 장소·world 텍스트 배선 + `bg_fill` 팩

참조 이미지 0 으로 사라지는 장소 외형 권위를 텍스트로 대체한다.
현재 서비스는 `outdoor_place_spec` CP 를 로드하지 않고 world 는
`classify.world_anchor_en` 한 줄뿐이며(`:188-189`)
`build_bgfirst_bg_prompt` 에는 인자 자체가 없다 — **배선 없이는 효과가
0** 이다.

**Files:**
- Create: `prompts/_base/still_recipe/<n>.<ts>/bg_fill_head.md`,
  `bg_fill_tail.md` (기존 팩 사본 디렉터리 안에)
- Modify: `backend/app/modules/pipeline/still_recipe.py:1094-1126`
  (`build_bgfirst_bg_prompt`), 신규 `build_place_facts_block`
- Modify: `backend/app/services/still_recipe_service.py` (CP 2종 로드,
  `:2328-2348` 프롬프트 호출)
- Test: `backend/tests/unit/test_still_recipe_bgfirst.py`

**Interfaces:**
- Consumes: T6 의 `LANE_CONTI_ONLY`,
  `core.world_context.build_world_facts_block`
- Produces:
  - `build_place_facts_block(spec: Dict[str, Any]) -> str`
  - `BGFIRST_LANE_PROMPT_VERSION: str`,
    `BGFIRST_LANE_CONTRACT_VERSION: str`
  - `build_bgfirst_bg_prompt(..., place_facts_block: str = "", world_facts_block: str = "", lane_fill: bool = False)`
  - 서비스 헬퍼 `_lane_place_facts(group_key) -> str` (fail-closed)

- [ ] **Step 1: 실패하는 테스트를 쓴다 — 절제 계약**

```python
SAMPLE_FIXTURE_SPEC = {
    "layout_narration_en": "The site is a paved waiting area beside a "
                           "vehicle roadway, with a shoreline behind it.",
    "zone_labels_en": ["Waiting Area"],
    "items": [
        {"code": "B1", "kind": "shelter",
         "name_en": "roofed waiting shelter",
         "placement_en": "Stands at the middle of the waiting area.",
         "inferred": False, "temporal_scope": "persistent_site"},
        {"code": "B2", "kind": "sign",
         "name_en": "fixed identification sign",
         "placement_en": "Beside the roadway edge.",
         "inferred": False, "temporal_scope": "persistent_site"},
    ],
    "excluded_transient_elements": [
        {"name_en": "temporary police control line",
         "reason_en": "installed for the investigation"},
    ],
}


def test_place_facts_block_is_restrained():
    from app.modules.pipeline.still_recipe import build_place_facts_block

    block = build_place_facts_block(SAMPLE_FIXTURE_SPEC)
    # 주입: narration + kind/name
    assert "paved waiting area" in block
    assert "roofed waiting shelter" in block
    assert "fixed identification sign" in block
    # 미주입: 배치 서술(콘티와 이중 권위), 코드, 제외 목록(프라이밍)
    assert "Stands at the middle" not in block
    assert "Beside the roadway edge" not in block
    assert "B1" not in block and "B2" not in block
    assert "police" not in block
    assert "Waiting Area" not in block  # zone 은 narration 과 중복


def test_bg_fill_prompt_requires_place_and_world_facts():
    import pytest
    from app.modules.pipeline.still_recipe import (
        BGFIRST_LANE_PROMPT_VERSION,
        build_bgfirst_bg_prompt,
    )

    p = build_bgfirst_bg_prompt(
        shot_desc="SAMPLE_FIXTURE_SHOT",
        place_text="SAMPLE_FIXTURE_PLACE",
        time_of_day_en="night",
        place_facts_block="- shelter: roofed waiting shelter",
        world_facts_block="- Region (real-world reference): SAMPLE",
        lane_fill=True,
        prompt_version=BGFIRST_LANE_PROMPT_VERSION)
    assert "roofed waiting shelter" in p
    assert "Region (real-world reference)" in p
    # 마네킹 보존 계약이 실려야 한다
    assert "mannequin" in p.lower()
    # 발명 유도 문구는 없어야 한다
    assert "such a place really has" not in p

    # lane_fill 인데 사실 블록이 비면 fail-closed
    with pytest.raises(ValueError):
        build_bgfirst_bg_prompt(
            shot_desc="S", place_text="P", time_of_day_en="night",
            place_facts_block="", world_facts_block="- Region: X",
            lane_fill=True, prompt_version=BGFIRST_LANE_PROMPT_VERSION)
    with pytest.raises(ValueError):
        build_bgfirst_bg_prompt(
            shot_desc="S", place_text="P", time_of_day_en="night",
            place_facts_block="- shelter: x", world_facts_block="",
            lane_fill=True, prompt_version=BGFIRST_LANE_PROMPT_VERSION)


def test_non_lane_bg_prompt_is_byte_identical():
    """기존 경로는 새 인자 default 로 조립 결과가 변하지 않는다."""
    from app.modules.pipeline.still_recipe import (
        BGFIRST_PROMPT_VERSION,
        build_bgfirst_bg_prompt,
    )

    kw = dict(shot_desc="S", place_text="P", time_of_day_en="night",
              prompt_version=BGFIRST_PROMPT_VERSION)
    assert build_bgfirst_bg_prompt(**kw) == build_bgfirst_bg_prompt(
        **kw, place_facts_block="", world_facts_block="", lane_fill=False)
```

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

Run: `cd backend && python -m pytest tests/unit/test_still_recipe_bgfirst.py -q -k "place_facts or bg_fill or byte_identical"`
Expected: FAIL — `ImportError: cannot import name 'build_place_facts_block'`

- [ ] **Step 3: 팩 디렉터리와 스템을 만든다**

```bash
cd /Users/manta/Documents/Projects/TheRoad-I1
TS=$(date +%Y%m%d%H%M)
# 현행 bgfirst full 팩 디렉터리를 확인해 그 사본으로 만든다
ls prompts/_base/still_recipe | sort -V | tail -3
```

가장 높은 selector 를 `N` 이라 하면 `N+1` 로 사본을 만든다:

```bash
cp -r prompts/_base/still_recipe/<N>.<old_ts> \
      prompts/_base/still_recipe/<N+1>.$TS
```

`prompts/_base/still_recipe/<N+1>.<ts>/bg_fill_head.md`:

```
Turn the attached storyboard sketch into a photorealistic film still of
its LOCATION ONLY, keeping the figures exactly as they are.

KEEP EXACTLY: the camera framing, the horizon, and where every element
sits in the frame. Each mannequin figure stays a plain grey featureless
mannequin standing in the very same spot, at the same size, in the same
pose, turned the same way — do not turn them into people, do not move,
rotate, mirror or re-pose them, do not add or remove figures.

BUILD PHOTOREALISTICALLY: everything that is not a figure — ground,
surfacing, structures, vegetation, sky, water, distance. The bare lines
of the sketch are a layout guide; replace them with the real materials,
depth and lighting of the place described below.
```

`prompts/_base/still_recipe/<N+1>.<ts>/bg_fill_tail.md`:

```
THINGS THAT LIVE AT THIS PLACE (what they are, not where they go): the
list above names what permanently belongs to this site. Where each one
sits in this frame is decided by the attached sketch alone — do not add
or reposition anything on the strength of the list, build only what the
sketch already has lines for, and do not invent facilities that are not
listed.

No text, no captions, no diagram symbols, no markers anywhere in the
image.
```

- [ ] **Step 4: `build_place_facts_block` 을 구현한다**

`still_recipe.py` 에 추가:

```python
def build_place_facts_block(spec: Dict[str, Any]) -> str:
    """장소 사실 — 절제 블록 (2026-07-26 사용자: "너무 디테일하게 넣으면
    안 돼").

    싣는 것: layout_narration_en 1단락 + items 의 kind/name_en.
    빼는 것:
      - placement_en — 배치 권위는 첨부 콘티의 선이다. 텍스트로 또 주면
        이중 권위가 되어 구도가 흔들린다.
      - code/evidence/inferred/zone_labels_en — 감사 필드이거나 narration
        과 중복.
      - excluded_transient_elements — 금지 대상 열거는 오히려 프라이밍
        이다(outdoor_marker_map.build_geometry_text_lines 의 구조 토큰
        비노출과 같은 계열).

    코드는 데이터 직렬화만 한다 — 의미 판단 없음.
    """
    if not isinstance(spec, dict):
        return ""
    lines: List[str] = []
    narration = str(spec.get("layout_narration_en") or "").strip()
    if narration:
        lines.append(narration)
    seen = set()
    for it in spec.get("items") or []:
        if not isinstance(it, dict):
            continue
        kind = str(it.get("kind") or "").strip()
        name = str(it.get("name_en") or "").strip()
        if not name or name in seen:
            continue
        seen.add(name)
        lines.append(f"- {kind}: {name}" if kind else f"- {name}")
    return "\n".join(lines)
```

- [ ] **Step 5: lane 전용 버전 상수와 프롬프트 조립을 고친다**

`still_recipe.py` 의 기존 `BGFIRST_PROMPT_VERSION` /
`BGFIRST_FULL_PROMPT_VERSION` 정의 옆에 추가:

```python
# lane(map_marker) 확정 흐름 전용 — v7(비 full)·v11(full) 과 역할이
# 갈라져 있어 한 상수에 세 역할을 섞지 않는다.
BGFIRST_LANE_PROMPT_VERSION = "<N+1>"
BGFIRST_LANE_CONTRACT_VERSION = "1"
```

`build_bgfirst_bg_prompt` 를 바꾼다:

```python
def build_bgfirst_bg_prompt(
    *,
    shot_desc: str,
    place_text: str,
    time_of_day_en: str,
    camera_frame_en: str = "",
    lighting_mood_en: str = "",
    place_facts_block: str = "",
    world_facts_block: str = "",
    lane_fill: bool = False,
    prompt_version: str = BGFIRST_PROMPT_VERSION,
) -> str:
```

docstring 끝에 추가:

```python
    lane_fill (2026-07-26 확정 흐름): 참조 이미지 0 으로 콘티 자체를
    편집하는 lane 경로. head/tail 스템이 bg_fill_* 로 바뀌고 장소·world
    사실 블록이 **필수**가 된다 — 둘 중 하나라도 비면 무국적 배경을
    조용히 만들게 되므로 ValueError(fail-closed). 기본값 False 는 기존
    조립과 byte-identical.
```

본문:

```python
    resolved = resolve_prompt_version(prompt_version)
    if lane_fill:
        if not place_facts_block.strip():
            raise ValueError(
                "lane_fill 인데 place_facts_block 이 비어 있음 — 장소 "
                "사실 없이 배경을 만들지 않는다 (fail-closed)"
            )
        if not world_facts_block.strip():
            raise ValueError(
                "lane_fill 인데 world_facts_block 이 비어 있음 — 지역·"
                "시대 없이 배경을 만들지 않는다 (fail-closed)"
            )
        parts = [
            load_prompt(_MODULE, "bg_fill_head", version=resolved).strip(),
            f"SHOT TEXT this background must serve (Korean): {shot_desc}",
            f"LOCATION (lock): {place_text}",
            f"TIME OF DAY (lock): {time_of_day_en}.",
        ]
    else:
        parts = [
            load_prompt(
                _MODULE, "bg_reproject_head", version=resolved).strip(),
            f"SHOT TEXT this background must serve (Korean): {shot_desc}",
            f"LOCATION (lock): {place_text}",
            f"TIME OF DAY (lock): {time_of_day_en}.",
        ]
    if camera_frame_en:
        parts.append(camera_frame_en)
    if lighting_mood_en:
        parts.append(lighting_mood_en)
    if lane_fill:
        parts.append("THINGS AT THIS PLACE:\n" + place_facts_block)
        parts.append(
            "WORLD FACTS (creator-confirmed — always true):\n"
            + world_facts_block)
        parts.append(
            load_prompt(_MODULE, "bg_fill_tail", version=resolved).strip())
    else:
        parts.append(
            load_prompt(
                _MODULE, "bg_reproject_tail", version=resolved).strip())
    return "\n\n".join(parts)
```

- [ ] **Step 6: 서비스에서 CP 2종을 로드하고 fail-closed 조회를 넣는다**

`still_recipe_service.py` 의 다른 `_load_cp` 호출들 근처(`:250` 부근
`background_share_plan` 로드 옆)에 추가한다:

```python
    # 2026-07-26 확정 흐름: 참조 0 배경의 장소·world 권위는 텍스트다.
    # lane ON 일 때 런당 1회 로드해 Step1 프롬프트에 싣는다.
    _place_spec_groups: Dict[str, Any] = (
        _load_cp(projects_dir, project_id, episode_id,
                 "outdoor_place_spec").get("data", {}) or {}
    ).get("groups", {}) or {}
    _vwr_cp = _load_cp(
        projects_dir, project_id, episode_id, "visual_world_rules")
    _lane_world_block = build_world_facts_block(_vwr_cp)

    def _lane_place_facts(group_key: str) -> str:
        """lane 샷의 장소 사실 블록 — 결손이면 fail-closed.

        성공 entry 는 {spec, attempts, outdoor_loc_ids, scene_indices}
        이고 `status` 키가 **없다**(실패 entry 만 error 를 갖는다,
        outdoor_place_spec_step.py:228-233). canon CP 는 status 를 갖는
        다른 shape 라 혼동하지 말 것.
        """
        entry = (_place_spec_groups or {}).get(group_key)
        if not isinstance(entry, dict) or entry.get("error"):
            raise ValueError(
                f"lane 배경: place spec 그룹({group_key!r}) 결손/실패 "
                f"({(entry or {}).get('error')!r}) — 장소 사실 없이 "
                "배경 생성 금지 (fail-closed)"
            )
        spec = entry.get("spec")
        if not isinstance(spec, dict):
            raise ValueError(
                f"lane 배경: place spec 그룹({group_key!r}) 의 spec 이 "
                "dict 아님 — fail-closed"
            )
        block = build_place_facts_block(spec)
        if not block.strip():
            raise ValueError(
                f"lane 배경: place spec 그룹({group_key!r}) 에서 장소 "
                "사실 블록이 비었음 — fail-closed"
            )
        return block
```

상단 import 에 추가한다:

```python
from app.core.world_context import build_world_facts_block
from app.modules.pipeline.still_recipe import build_place_facts_block
```

(모듈 상단 import 가 순환을 만들면 `run_still_recipe_generation` 안의
지연 import 로 옮긴다 — 이 파일은 지연 import 관례를 쓴다.)

- [ ] **Step 7: Step1 프롬프트 호출을 lane 분기로 바꾼다**

`still_recipe_service.py:2328-2348` 의 `bg_prompt = build_bgfirst_bg_prompt(...)`
호출에 lane 인자를 넘긴다:

```python
                _lane_fill = _authority_kind == LANE_CONTI_ONLY
                bg_prompt = build_bgfirst_bg_prompt(
                    shot_desc=shot_desc_by_id.get(still_id)
                    or s.get("still_frame_prompt") or "",
                    place_text=(
                        cls.get("place_en")
                        or classify_scenes.get(str(si), {}).get("place_en")
                        or location_by_scene.get(si, "")
                    ),
                    time_of_day_en=(
                        classify_scenes.get(str(si), {})
                        .get("time_of_day_en") or ""
                    ),
                    camera_frame_en=_bg_cam,
                    lighting_mood_en=lighting_mood_en,
                    place_facts_block=(
                        _lane_place_facts(lane_entry.get("group_id") or "")
                        if _lane_fill else ""
                    ),
                    world_facts_block=(
                        _lane_world_block if _lane_fill else ""
                    ),
                    lane_fill=_lane_fill,
                    prompt_version=(
                        BGFIRST_LANE_PROMPT_VERSION if _lane_fill
                        else _BGF_FULL_PACK_SEL if bgfirst_full_on
                        else _BGFIRST_PACK
                    ),
                )
```

`BGFIRST_LANE_PROMPT_VERSION` 을 같은 블록 import 에 추가한다.

`group_id` 는 실측으로 확인된 필드다 — lane CP 의 map_marker entry 는
`{asset_id, base_map_asset_id, base_map_path, geometry,
geometry_attempts, group_id, image_path, lane, leakage, prompt,
segment_id, sketch_attempts, status}` 를 갖고 `group_id` 값은
place spec 의 그룹 키와 같은 형식(예: `bg_rooftop_residence`)이다.
`group_id` 가 비면 `_lane_place_facts("")` 가 그룹 부재로 fail-closed
하므로 별도 가드는 필요 없다.

- [ ] **Step 8: 지문에 lane 계약을 접는다**

`still_recipe_service.py:496-506` 의 `extra_fingerprint` 블록에 추가:

```python
        if bool(getattr(settings, "still_lane_prev_bgfirst_enabled", False)):
            extra_fingerprint["bgfirst_lane_pack"] = recipe_pack_version(
                BGFIRST_LANE_PROMPT_VERSION)
            extra_fingerprint["bgfirst_lane_contract"] = (
                BGFIRST_LANE_CONTRACT_VERSION)
```

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

Run: `cd backend && python -m pytest tests/unit/test_still_recipe_bgfirst.py tests/unit/test_still_recipe.py -q`
Expected: PASS

- [ ] **Step 10: 커밋**

```bash
git add prompts/_base/still_recipe \
        backend/app/modules/pipeline/still_recipe.py \
        backend/app/services/still_recipe_service.py \
        backend/tests/unit/test_still_recipe_bgfirst.py
git commit -m "feat(lane): 배경 i2i 장소·world 텍스트 배선 + bg_fill 팩

참조 0 으로 사라지는 장소 외형 권위를 place spec 절제 블록(narration+
items kind/name)과 world facts 로 대체한다. placement_en·제외 목록·code 는
넣지 않는다(콘티와 이중 권위 회피 / 금지 대상 프라이밍 회피).

서비스는 outdoor_place_spec CP 를 로드조차 하지 않았고 world 는
classify.world_anchor_en 한 줄뿐이어서 배선 없이는 효과가 0 이었다.
성공 entry 는 status 키가 없으므로 spec dict + error 부재를 성공 조건으로
쓴다. 사실 블록이 비면 fail-closed."
```

---

