"""confined(좁고 복잡한 조작 공간) 샷 — fp readback 부품 (2026-08-10).

설계 SOT: docs/superpowers/specs/2026-08-10-confined-interior-fp-readback-production-design.md
실험 SOT: backend/car_interior_ab_experiment.py + artifact/20260810_car_interior_ab/

자동차 전용이 아니다 — 전투로봇 콕핏·장갑차·버스·항공기·조타실 등
조작 장치가 많은 모든 밀폐 공간(사용자 확정 2026-08-10). 공간의 실체는
전부 브리프의 LOCATION/SHOT 텍스트가 정하고, 이 부품의 계약(팩)은
범용 어휘만 쓴다 — 특정 장치·차종·방향 하드코딩 금지.

흐름: ①정밀 fp 작화(gpt-image-2 — gemini 는 조작 장치 위치를 반복해서
틀린 실측, location_floor_plan 관례와 동일 부품) ②VLM readback(도면
읽기+브리프 대조 mismatches+카메라 시점 장면 설명 — 브리프 내부 모순
검출기 겸용) ③mismatch 시 gpt i2i 국소 수정 1회+재검증 ④생성에는
fp 도면+장면 설명을 함께 준다(잔류 금지 라벨).
"""
from __future__ import annotations

import logging
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple

logger = logging.getLogger(__name__)

_MODULE = "confined_fp"

CONFINED_FP_PACK_VERSION_MAP = {
    "1": "1.202608110030",
    # v2 (08-11 육안 20샷 반영): "정밀"이 세밀 렌더링으로 새던 계약을
    # **간략 구도 도면**으로 교체(base_fp_head — 좌석 배열+조작 장치+
    # 조건부 미러만, 디테일·투시·실사·PIP 금지 명문) + base 도면을 같은
    # 공간 샷들이 공유하고 샷별 마커만 얹는 분리(shot_fp_mark_head).
    "2": "2.202608110130",
}
CONFINED_FP_PACK_VERSION = "2"
# 정책 버전 — 지문 기여(ON 시). 흐름이 바뀌면 올린다.
# v2: base 공유 도면+샷 마커 분리, 마커·readback 입력에 인물 배치 절
# (CARRIED STATE·PEOPLE) 동반, fp 적용 판별(LLM) 도입.
CONFINED_FP_POLICY_VERSION = "confined_fp_v2_shared_base"

# fp 적용 판별 — confined_structure=True 중에서도 fp 가 돕는 샷만
# (2026-08-11 사용자 확정: 창고류 단순 공간·공간 외부 샷 제외 — "사람들은
# 창고 안 물체 구분이 어렵고 바로 실사로 찍어도 일관성을 잘 캐치 못 한다").
# 판별은 LLM 구조화 판정만 — 글자·어휘 판단 금지.
APPLICABILITY_SYS = """\
You decide whether a floor-plan layout aid would help generate this shot.

Answer `applies: true` ONLY if BOTH hold:
1. The shot takes place INSIDE an enclosed, control-heavy space — an
   operator's or crew's interior where WHO SITS WHERE relative to the
   controls matters (a vehicle cabin, cockpit, wheelhouse, armored crew
   bay, bus, aircraft, a machine's operator station) — OR the SHOT TEXT
   aims a directional object whose pointing must read correctly.
2. Getting the seating/station arrangement or that aim wrong would be
   an obvious, story-breaking mistake.

Answer `applies: false` when the space is a simple storage or open
interior whose object arrangement ordinary viewers cannot tell apart
(a straight photograph reads fine there), or when the shot is not
actually inside the space. Write `reason_ko` in Korean."""
FP_SIZE = "1536x1024"
FP_REF_LABEL = ("FLOOR PLAN — layout authority, a diagram, never scenery")


def resolve_confined_fp_pack(selector: str = CONFINED_FP_PACK_VERSION) -> str:
    try:
        return CONFINED_FP_PACK_VERSION_MAP[selector]
    except KeyError:
        raise ValueError(
            f"unknown confined_fp pack selector {selector!r} "
            f"(known: {sorted(CONFINED_FP_PACK_VERSION_MAP)})"
        )


def confined_fp_pack_content_hash(
    selector: str = CONFINED_FP_PACK_VERSION,
) -> str:
    """팩 bytes 지문 (#77 관례 — 버전 문자열이 아니라 내용)."""
    from app.modules.prompt_loader import pack_dir_content_hash

    return pack_dir_content_hash(_MODULE, resolve_confined_fp_pack(selector))


def _load(stem: str, selector: str = CONFINED_FP_PACK_VERSION) -> str:
    from app.modules.prompt_loader import load_prompt

    return load_prompt(
        _MODULE, stem, version=resolve_confined_fp_pack(selector))


def build_fp_readback_schema() -> Dict[str, Any]:
    return {
        "type": "object", "additionalProperties": False,
        "properties": {
            "reads": {
                "type": "object", "additionalProperties": False,
                "properties": {
                    "controls": {"type": "string"},
                    "mirrors": {"type": "string"},
                    "camera": {"type": "string"},
                    "occupants": {"type": "string"},
                },
                "required": ["controls", "mirrors", "camera", "occupants"],
            },
            "mismatches": {"type": "array", "items": {"type": "string"}},
            "scene_description_en": {"type": "string"},
        },
        "required": ["reads", "mismatches", "scene_description_en"],
    }


def strip_camera_block(prompt: str) -> str:
    """브리프에서 CAMERA & FRAME 블록만 뺀 전문 — confined 생성 전용.

    실측(2026-08-10): 화면 배치 문자("운전대는 오른쪽")가 조작 장치 소속
    좌석의 결정론 없이 실리면 모든 경로가 반대 배치로 수렴한다. 구도
    권위는 fp+장면 설명이 대신한다. 브리프 저작 자체는 불변 — 제거는
    confined 생성 호출에서만(설계 결정 2).

    ★**끝나는 자리가 하나뿐이라 뒤가 통째로 사라졌다** (2026-08-27).
     종전에는 `Compose the frame` 으로 시작하는 줄에서만 멈췄다. 그런데
     그 문장은 팩 v6 에만 있고 **컴팩트 팩 v22 에는 없다** — grok 조립 +
     confined 경로에서 CAMERA 이후 프롬프트 **전부**가 잘린 채 유료 호출로
     갔다. 시네마틱 마감 절만이 아니라 그 뒤의 모든 절이.

     고치는 자리는 팩이 아니라 여기다. 컴팩트 스템에 종료 문구를 넣으면
     **나가는 v22 바이트가 바뀌어** 완주본이 재생성된다. 여기서 **다음
     top-level 헤딩**에서도 끝내면(그 헤딩은 **보존**) 나가는 문안은
     그대로고 잘리던 것만 안 잘린다.

     `Compose the frame` 갈래는 **그대로 둔다** — v6 로 조립한 브리프의
     바이트가 한 자도 안 바뀌어야 한다(그 줄은 종전처럼 버린다).
    """
    out: List[str] = []
    in_cam = False
    for ln in prompt.split("\n"):
        s = ln.strip()
        if s.startswith("CAMERA & FRAME"):
            in_cam = True
            continue
        if in_cam:
            if s.startswith("Compose the frame"):
                # v6 갈래 — 이 줄까지가 CAMERA 블록이다.
                in_cam = False
                continue
            if _HEADER_RE.match(s):
                # 다음 절이 시작됐다 — 블록은 끝났고 **이 줄은 남긴다**.
                in_cam = False
                out.append(ln)
                continue
            continue
        out.append(ln)
    return "\n".join(out)


_HEADER_RE = __import__("re").compile(r"^[A-Z][A-Z &/()'-]{4,}")


def extract_brief_sections(prompt: str) -> Dict[str, str]:
    """브리프 고정 헤더 절 발췌(팩 헤더 문구의 형식 소비) — fp 입력용.

    없는 절은 빈 문자열(브리프 구성은 샷마다 다르다 — 발췌는 입력을
    좁히는 용도이고 생성은 전문을 따로 받는다).

    carried/people (v2): 인물이 어느 자리에 있는지의 원천 절 — fp 샷
    마커·readback 입력에 없으면 뒷좌석 인물이 도면에서 빠진다(실측).
    두 절은 헤더 줄부터 다음 대문자 헤더 전까지의 블록.
    """
    out = {"shot_text": "", "location": "", "camera": "", "lighting": "",
           "carried": "", "people": ""}
    cam: List[str] = []
    in_cam = False
    block_key = ""
    block: List[str] = []

    def flush() -> None:
        nonlocal block_key, block
        if block_key:
            out[block_key] = "\n".join(block).strip()
        block_key, block = "", []

    for ln in prompt.split("\n"):
        s = ln.strip()
        if block_key and _HEADER_RE.match(s):
            flush()
        if s.startswith("SHOT TEXT"):
            out["shot_text"] = s
        elif s.startswith("LOCATION"):
            out["location"] = s
        elif s.startswith("CARRIED STATE"):
            flush()
            block_key, block = "carried", [s]
            continue
        elif s.startswith("PEOPLE:"):
            flush()
            block_key, block = "people", [s]
            continue
        elif s.startswith("CAMERA & FRAME"):
            in_cam = True
            cam.append(s)
            continue
        elif s.startswith("- LIGHTING & MOOD"):
            out["lighting"] = s
            in_cam = False
        elif in_cam:
            cam.append(s)
            if s.startswith("Compose the frame"):
                in_cam = False
        elif block_key:
            block.append(ln)
    flush()
    out["camera"] = "\n".join(cam)
    return out


def fp_context(sections: Dict[str, str]) -> str:
    # v2: 인물 배치 절(carried/people) 포함 — readback 대조에서 도면의
    # 인물 마커가 "텍스트에 없는 요소"로 오탐되지 않게.
    return "\n\n".join(x for x in (
        sections.get("shot_text"), sections.get("carried"),
        sections.get("people"), sections.get("location"),
        sections.get("camera"), sections.get("lighting")) if x)


def _gpt_fp_image(prompt: str, out_path: Path,
                  ref: Optional[Path] = None,
                  capture_role: str = "confined_fp") -> None:
    """fp 작화·수정 — gpt-image (기록은 부품에 내장)."""
    from app.core.config import settings
    from app.core.openai_keys import openai_client
    from app.modules.llm.gpt_image_primitive import call_gpt_image_bytes
    from app.core.image_call_budget import reserve_current_call

    client = openai_client(timeout=float(settings.llm_timeout_image_gen))
    # ★이미지 문 — run-wide cap 을 **모든** gpt-image 자리가 지난다
    reserve_current_call(source="confined_fp.draw")
    png = call_gpt_image_bytes(
        client,
        mode="edit" if ref else "generate",
        prompt=prompt,
        ref_paths=[ref] if ref else None,
        call_kwargs={"model": settings.openai_image_model,
                     "size": FP_SIZE, "quality": "high"},
        capture_role=capture_role)
    if not png:
        raise RuntimeError("confined_fp: gpt fp 생성 빈 응답")
    out_path.parent.mkdir(parents=True, exist_ok=True)
    out_path.write_bytes(png)


def _readback(step_tag: str, fp_path: Path, ctx: str,
              project_config: Optional[Dict[str, Any]],
              opik_metadata: Optional[Dict[str, Any]]) -> Dict[str, Any]:
    from app.modules.llm.llm_client import EmptyLLMResponse, call_structured
    from app.modules.pipeline.multiroll_gemini import (
        SELECT_JUDGE_MODEL_2,
        png_part,
    )

    sys_p = _load("fp_readback_sys")
    parts = [{"type": "text",
              "text": "LOCATION / CAMERA / SHOT TEXT:\n" + ctx},
             {"type": "text", "text": "THE FLOOR-PLAN DIAGRAM:"},
             png_part(fp_path)]
    schema = build_fp_readback_schema()

    def _ask(tag: str, model: str, **seal: Any) -> Dict[str, Any]:
        return call_structured(
            tag, sys_p, parts, schema,
            project_config={**(project_config or {}), tag: {"model": model}},
            schema_name="confined_fp_readback",
            opik_metadata=opik_metadata,
            enable_fallback=False, **seal)

    try:
        return _ask(step_tag, "gemini-pro")
    except EmptyLLMResponse as first:
        # ★빈 응답**만** 둘째 관찰자에게 한 번 더 보인다 (2026-09-19 S48sh5
        #  — 같은 샷에서 여섯 번 연속 빈 응답, 샷이 매번 죽었다).
        #  · 같은 도면 bytes · 같은 글 · 같은 스키마. 글을 부드럽게 하거나
        #    이 확인을 건너뛰지 않는다.
        #  · 멈춤·예산·마감은 타입이 달라 여기 안 걸린다(그대로 올라간다).
        #  · 빈 응답의 원인은 모른다 — 검열로 단정하지 않는다.
        #  · 둘째도 실패하면 그대로 올라가 샷이 실패한다(통과로 안 친다).
        #  · 모델은 지금 승인된 **둘째 관찰자**(수정 관찰과 같은 자리).
        #    Router 재시도를 0 으로 — 「한 번 더」가 네 번이 되지 않게.
        logger.warning(
            "confined_fp readback[%s]: 첫 관찰 빈 응답 — 둘째 관찰자(%s)로 "
            "한 번 더: %s", step_tag, SELECT_JUDGE_MODEL_2, first)
        _sink: Dict[str, Any] = {}
        rb = _ask(f"{step_tag}_observe_2", SELECT_JUDGE_MODEL_2,
                  num_retries=0, usage_sink=_sink)
        return {**rb, "readback_fallback": {
            "first_model": "gemini-pro",
            "first_error": str(first),
            "model": SELECT_JUDGE_MODEL_2,
            "physical_model": _sink.get("physical_model"),
        }}


def produce_confined_fp(
    *,
    tag: str,
    brief: str,
    out_dir: Path,
    project_config: Optional[Dict[str, Any]] = None,
    opik_metadata: Optional[Dict[str, Any]] = None,
    pack_version: str = "1",
) -> Tuple[Path, Dict[str, Any]]:
    """[v1 흐름 — 팩 v1 전용] 샷 단독 fp 산출 — (fp_path, readback dict).

    v2(base 공유+샷 마커: `produce_base_fp`+`produce_shot_fp`)가 현행이다.
    이 함수는 v1 팩 스템(fp_head) 전제라 selector "1" 고정 — 기록·재현용
    으로 남긴다(기존 모듈 삭제 금지 관례).

    readback dict = {reads, mismatches, scene_description_en, fixed}.
    mismatches 는 durable record 로 남길 것(브리프 내부 모순 보고 포함 —
    실측: LOCATION 문자 vs 미러 반사 요구의 모순을 정확히 보고했다).
    """
    sections = extract_brief_sections(brief)
    ctx = fp_context(sections)
    if not ctx.strip():
        raise ValueError(
            f"confined_fp[{tag}]: 브리프에서 발췌 절 0 — fp 를 그릴 입력이 "
            "없다 (fail-closed)")
    head = _load("fp_head", pack_version)
    base_fp = out_dir / f"{tag}_fp_draft.png"
    fp_path = out_dir / f"{tag}_fp.png"
    _gpt_fp_image(head + "\n\n" + ctx, base_fp)
    rb = _readback(f"confined_fp_readback_{tag}", base_fp, ctx,
                   project_config, opik_metadata)
    fixed = False
    if rb.get("mismatches"):
        logger.info(
            "confined_fp[%s]: fp 어긋남 %d건 — i2i 국소 수정",
            tag, len(rb["mismatches"]))
        _gpt_fp_image(
            _load("fp_fix_head", pack_version) + "\n- "
            + "\n- ".join(rb["mismatches"]) + "\n\n" + ctx,
            fp_path, ref=base_fp)
        rb = _readback(f"confined_fp_readback_{tag}_fix", fp_path, ctx,
                       project_config, opik_metadata)
        fixed = True
    else:
        import shutil

        shutil.copy(base_fp, fp_path)
    desc = str(rb.get("scene_description_en") or "").strip()
    if not desc:
        raise ValueError(
            f"confined_fp[{tag}]: 장면 설명이 비었다 — 배치 권위 없이 "
            "생성하지 않는다 (fail-closed)")
    return fp_path, {**rb, "fixed": fixed}


def build_confined_gen_prompt(
    scene_description_en: str,
    brief: str,
    pack_version: str = CONFINED_FP_PACK_VERSION,
) -> str:
    """confined 생성 프롬프트 — gen_head + 장면 설명 + 정리된 브리프.

    브리프에서 CAMERA 절과 prev 참조 전제 절을 뺀다(strip_confined_brief)
    — 실제 첨부([fp, 엔티티])와 지시가 일치해야 한다 (Codex BLOCK-4).
    """
    return (
        _load("gen_head", pack_version).strip()
        + "\n\nSCENE LAYOUT (what a careful reader saw in the attached "
        "floor plan):\n" + scene_description_en.strip()
        + "\n\n" + strip_confined_brief(brief)
    )


# 첨부를 전제하는 브리프 절 — confined 생성의 실제 첨부는 [fp 도면,
# 엔티티]뿐이라 이 절들이 남으면 "없는 첨부를 따르라"는 거짓 지시가 된다
# (Codex BLOCK-4 + 재리뷰 BLOCK-2). 헤더 문구는 브리프 팩·build_still_
# prompt 조립 실측(2026-08-11): prev 2종 + 복잡 구조물 STRUCTURE LOOK.
_NON_ATTACHED_AUTHORITY_HEADS = (
    "THIS SHOT CONTINUES THE PREVIOUS SHOT",
    "PREVIOUS STILL USAGE",
    "STRUCTURE LOOK AUTHORITY",
)
# LOCATION 줄의 조립 고정 접속구(still_recipe build_still_prompt) — 이
# 뒤에 첨부 사진 권위 꼬리(LOCATION PHOTOGRAPH / LOCATION STRUCTURE
# PHOTOGRAPH / chain 스템)가 붙는다. 장소 텍스트는 남기고 꼬리만 자른다.
_LOCATION_TAIL_MARKER = " The shot takes place here — "


def strip_confined_brief(prompt: str) -> str:
    """confined 생성용 브리프 정리 — 첨부 전제 절·꼬리 제거.

    제거: ①CAMERA 절 ②prev 참조 전제 절 2종 ③STRUCTURE LOOK 권위 절
    ④LOCATION 줄의 첨부 사진 권위 꼬리(장소 텍스트는 보존). 실제 첨부와
    지시가 일치해야 한다 — 세 실조립 모드(default/structure_seed/seed_bg)
    회귀로 잔존 0 을 잠근다.
    """
    out: List[str] = []
    in_drop = False
    for ln in strip_camera_block(prompt).split("\n"):
        s = ln.strip()
        if in_drop and _HEADER_RE.match(s):
            in_drop = False
        if any(s.startswith(h) for h in _NON_ATTACHED_AUTHORITY_HEADS):
            in_drop = True
            continue
        if in_drop:
            continue
        if s.startswith("LOCATION") and _LOCATION_TAIL_MARKER in ln:
            ln = (ln.split(_LOCATION_TAIL_MARKER, 1)[0]
                  + " The shot takes place here.")
        out.append(ln)
    return "\n".join(out)


def _sha16(*parts: Any) -> str:
    import hashlib

    h = hashlib.sha256()
    for p in parts:
        h.update(str(p).encode("utf-8"))
        h.update(b"\x00")
    return h.hexdigest()[:16]


def apt_fingerprint(brief: str,
                    pack_version: str = CONFINED_FP_PACK_VERSION) -> str:
    """적용 판별 캐시의 입력 지문 — 계약·스키마·물리 모델·브리프 전문.

    존재 캐시는 새 설정을 옛 판별로 봉인한다(Codex BLOCK-2 — #77 과
    같은 부류). 재료가 하나라도 바뀌면 재판별한다.
    """
    import json as _json

    from app.core.config import settings

    return _sha16(brief, APPLICABILITY_SYS,
                  _json.dumps(build_applicability_schema(), sort_keys=True),
                  settings.gemini_text_model, pack_version)


def base_fingerprint(place_text: str,
                     pack_version: str = CONFINED_FP_PACK_VERSION) -> str:
    """base 도면 캐시의 입력 지문 — 장소 절+작화 스템 bytes+gpt 모델·크기."""
    from app.core.config import settings

    return _sha16(place_text, _load("base_fp_head", pack_version),
                  settings.openai_image_model, FP_SIZE)


def shot_fingerprint(base_path: Path, sections: Dict[str, str],
                     pack_version: str = CONFINED_FP_PACK_VERSION) -> str:
    """샷 fp 캐시의 입력 지문 — base bytes+절 전문+스템 3종+물리 모델 2종."""
    import hashlib
    import json as _json

    from app.core.config import settings

    base_bytes = hashlib.sha256(Path(base_path).read_bytes()).hexdigest()
    return _sha16(base_bytes,
                  _json.dumps(sections, sort_keys=True, ensure_ascii=False),
                  _load("shot_fp_mark_head", pack_version),
                  _load("fp_readback_sys", pack_version),
                  _load("fp_fix_head", pack_version),
                  settings.openai_image_model, settings.gemini_text_model)


# ── v2 흐름 — base 공유 도면 + 샷 마커 + 적용 판별 ────────────────────

def build_applicability_schema() -> Dict[str, Any]:
    return {
        "type": "object", "additionalProperties": False,
        "properties": {
            "applies": {"type": "boolean"},
            "reason_ko": {"type": "string"},
        },
        "required": ["applies", "reason_ko"],
    }


def judge_fp_applicability(
    tag: str,
    brief: str,
    *,
    project_config: Optional[Dict[str, Any]] = None,
    opik_metadata: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
    """fp 적용 판별 — confined 샷 중 fp 가 돕는 샷만 (LLM 구조화 판정).

    창고류 단순 공간·공간 외부 샷은 false → 기존 경로 그대로. 호출측은
    결과를 record 에 캐시해 재개 시 재판별 지출을 막을 것.
    """
    from app.modules.llm.llm_client import call_structured

    step_tag = f"confined_fp_apt_{tag}"
    return call_structured(
        step_tag, APPLICABILITY_SYS,
        "SHOT BRIEF (full):\n" + brief,
        build_applicability_schema(),
        project_config={**(project_config or {}),
                        step_tag: {"model": "gemini-pro"}},
        schema_name="confined_fp_apt",
        opik_metadata=opik_metadata,
        enable_fallback=False)


def produce_base_fp(
    space_tag: str,
    location_text: str,
    out_path: Path,
    pack_version: str = CONFINED_FP_PACK_VERSION,
) -> Path:
    """공간 단위 base 도면 — 좌석 배열+조작 장치(+조건부 미러)만.

    같은 공간의 샷들이 이 한 장을 공유한다(샷마다 다른 공간을 그리던
    결함 제거). 캐시 키·재사용은 호출측 몫(장소 단위).
    """
    if not (location_text or "").strip():
        raise ValueError(
            f"confined_fp[{space_tag}]: LOCATION 절이 비어 base 도면을 "
            "그릴 수 없다 (fail-closed)")
    _gpt_fp_image(
        _load("base_fp_head", pack_version) + "\n\n" + location_text,
        out_path, capture_role="confined_fp_base")
    return out_path


def produce_shot_fp(
    tag: str,
    base_fp: Path,
    sections: Dict[str, str],
    out_path: Path,
    *,
    project_config: Optional[Dict[str, Any]] = None,
    opik_metadata: Optional[Dict[str, Any]] = None,
    pack_version: str = CONFINED_FP_PACK_VERSION,
) -> Tuple[Path, Dict[str, Any]]:
    """샷 마커 도면 — base 에 카메라·인물·방향성 소품 마커만 i2i 로.

    마커 입력에 인물 배치 절(CARRIED STATE·PEOPLE)을 반드시 동반한다
    (v2 실측: 없으면 뒷좌석 인물이 도면에서 빠진다). readback 검증 후
    mismatch 가 있으면 지적을 지시로 i2i 국소 수정 1회+재검증.
    반환 = (fp_path, readback dict{reads,mismatches,scene_description_en,
    fixed}) — mismatch 는 record 로 남길 것(브리프 모순 보고 포함).
    """
    mark_ctx = "\n\n".join(x for x in (
        sections.get("shot_text"), sections.get("carried"),
        sections.get("people"), sections.get("camera")) if x)
    if not mark_ctx.strip():
        raise ValueError(
            f"confined_fp[{tag}]: 마커 입력 절이 비었다 (fail-closed)")
    ctx = fp_context(sections)
    _gpt_fp_image(
        _load("shot_fp_mark_head", pack_version) + "\n\n" + mark_ctx,
        out_path, ref=base_fp, capture_role="confined_fp_mark")
    rb = _readback(f"confined_fp_readback_{tag}", out_path, ctx,
                   project_config, opik_metadata)
    fixed = False
    if rb.get("mismatches"):
        logger.info(
            "confined_fp[%s]: 샷 도면 어긋남 %d건 — i2i 국소 수정",
            tag, len(rb["mismatches"]))
        _gpt_fp_image(
            _load("fp_fix_head", pack_version) + "\n- "
            + "\n- ".join(rb["mismatches"]) + "\n\n" + ctx,
            out_path, ref=out_path, capture_role="confined_fp_fix")
        # ★처음 확인의 대체 기록을 **남긴다** (Codex 리뷰). 수정 뒤 확인이
        #  `rb` 를 덮으면 처음 확인이 둘째 관찰자로 갔다는 사실이 사라져,
        #  마지막 기록만 보고 「대체 없음」으로 읽힌다.
        _initial_fb = rb.get("readback_fallback")
        rb = _readback(f"confined_fp_readback_{tag}_fix", out_path, ctx,
                       project_config, opik_metadata)
        if _initial_fb:
            rb = {**rb, "readback_fallback_initial": _initial_fb}
        fixed = True
    desc = str(rb.get("scene_description_en") or "").strip()
    if not desc:
        raise ValueError(
            f"confined_fp[{tag}]: 장면 설명이 비었다 — 배치 권위 없이 "
            "생성하지 않는다 (fail-closed)")
    return out_path, {**rb, "fixed": fixed}
