"""multiroll_gemini — multiroll_select 의 production 어댑터 (2026-07-13).

nb2(GeminiImageClient) 생성 + Gemini 단독 VLM 판정/결함 검사(call_structured
멀티모달 parts) 어댑터를 still_recipe_service 와 plate_multiroll 이 공용한다.
판정·결함 계약 텍스트는 prompts/_base/multiroll_judge 팩(SOT).
"""
from __future__ import annotations

import base64
import json
import logging
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple

logger = logging.getLogger(__name__)

JUDGE_MODULE = "multiroll_judge"
JUDGE_MODEL = "gemini-pro"  # 결함 critique·참조 선택 판정 = Gemini

# ── 후보 선정 판정 모델 (2026-08-06 사용자 지시) ─────────────────────
# 선정 판정만 Claude Opus 로 옮긴다. critique(`make_gemini_critique_fn`)와
# 참조 사진 선택(`make_ref_pick_judge_fn`)은 Gemini 그대로다.
#
# 근거는 16샷 전수 육안 대조다 — 사용자 판정과 Gemini 판정이 일치한 것이
# 1건뿐이었고, 틀린 축이 ①방향(총구·시선이 상대를 향하는가) ②복잡 구조물
# 내부 공간(핸들 이중·운전석/조수석·좌석 회전) ③중요 엔티티의 정체(어느
# 나라 지폐인가·목걸이 펜던트가 그 물건인가)로 몰려 있었다.
#
# ★키가 없으면 alias 가 Router 에 등록되지 않는다(`_build_router`) — 그때는
#  기존 `gemini-pro` 로 남는다. 키 없이 배포해도 경로가 죽지 않는다.
SELECT_JUDGE_MODEL = "claude-opus"
# 두 번째 심판 — 복잡 구조물 기하 전담. 4모델 대조 실측(2026-08-06)이 근거다:
# 핸들이 이중으로 겹친 이미지에 Opus·Fable 은 "1개"라 답했고 Sol 만 2,2 로
# 맞혔다. 대조군(명백히 1개)에서도 Sol 은 1,1 로 정확했다 — 과탐지가 아니다.
# Gemini 는 그 대조군에서 2개, 손이 쥔 폰에 "닿은 것 없음"이라 답해 제외했다.
# ★2026-09-08 사용자 지시: 「vlm 판단 부분도 grok 대신 gpt-6-astra 로 하고
#  전부 high 로」「즉, gemini + gpt-6-astra(high)」 — 물리 모델은 `gpt` 와
#  같고 **추론 강도만 high** 인 슬롯이다. `gpt` 를 그대로 쓰면 판정 자리와
#  일반 스텝의 강도를 못 가른다.
SELECT_JUDGE_MODEL_2 = "gpt-high"
# 하드 위반 1건당 정규화 점수에서 깎는 양. 처음엔 위반이 하나라도 있으면
# 탈락시켰는데 7샷 전부에서 모든 후보가 탈락해 필터가 무의미해졌다 — 두
# 심판이 각자 위반을 넉넉히 적기 때문이다. 개수 페널티로 바꾸니 승자가 모든
# 샷에서 위반 최소 후보와 일치했고, 계수를 0~0.40 으로 흔들어도 승자가 전부
# 그대로였다(저장된 판정으로 재계산, 유료 콜 0).
SELECT_VIOLATION_PENALTY = 0.25
# 조건부 이중 — 첫 심판이 1위를 이 폭 이상으로 앞세우면 둘째를 부르지 않는다.
# 208샷 실측(2026-08-06): Sol 이 판정을 뒤집은 것은 23건(11.2%)이고 그중
# 22건이 첫 심판의 정규화 점수차 0.30 **미만**에서 일어났다. 문턱 0.30 에서
# 둘째 호출의 48.6% 를 생략하면서 뒤집힘의 95.7% 를 그대로 잡는다.
#
# ★"첫 심판이 하드 위반을 못 봤는가"는 조건에 넣지 않았다 — 같은 놓침률에서
#  절감이 10%p 낮았다. 둘째 심판이 하드 위반을 남발하기 때문이다(한쪽만 잡은
#  위반 139건 대 17건, 내용은 "지붕 윤곽이 완전히 같지 않다" 급). 위반 유무는
#  판별력이 약하고 점수차가 진짜 신호다.
#
# ★표본 주의: 뒤집힘이 23건뿐이라 이 문턱은 과적합 위험이 있다. 0.30 은
#  "1위를 2위보다 30% 이상 앞세웠다"는 해석 가능한 값이라 택했다.
#  0.0 으로 내리면 항상 이중(기존 동작).
SELECT_DUAL_MARGIN_SKIP = 0.30

# ── G+Q 판정 체계 (2026-08-10 사용자 확정 — 설계 SOT:
# docs/superpowers/specs/2026-08-10-dual-judge-selection-redesign-design.md) ──
# `multiroll_gq_judge_enabled` ON 일 때 선정·fix-rejudge 심판 = Gemini+Qwen
# **동시**(margin-skip 없음). Qwen 은 Router 미등록(json_schema 미지원) —
# 이 센티널을 `make_gemini_judge_fn._one` 이 qwen_vlm_client 로 디스패치한다.
QWEN_JUDGE_MODEL = "qwen-vlm"
# 합의 규칙: winner 일치 또는 "Qwen 눈의 격차"(Qwen 정규화 점수에서 Q 승자 −
# G 승자) < 이 값 → Gemini 채택. 이상 → combine_select_verdicts 합산 합의.
# 근거 = 255샷 실측(artifact/20260809_final_eval_gallery): winner 일치 68.6%,
# 불일치 80건 중 격차<0.2 가 22건(Qwen 도 크게 반대하지 않는 구간) — 합쳐
# 77.3% 가 Gemini 채택 구간. 문턱을 낮추면 합산 경로가, 높이면 Gemini 단독
# 채택이 늘어난다.
GQ_DISAGREE_MARGIN = 0.20
# 합의 정책 버전 — 지문 기여(ON 시). 문턱이 문자열에 접혀 있어 문턱 변경이
# 곧 정책 버전 변경 = 산출 무효화로 이어진다.
GQ_SELECT_POLICY_VERSION = "gq_select_v1_margin020"

# ── QK 판정 체계 (2026-08-12 사용자 확정 "qwen 3.8 max main -> kimi k3
# 보조, gemini 버리자") — 멀티롤 선정·수정 재판정의 판정 모델 전환:
# 메인(취합·우선 자리)=Qwen 3.8 Max, 보조(관찰·교차 자리)=Kimi K3,
# 경로=OpenRouter(재평가 실측=build_qk_openrouter_eval.py). 합의 수학은
# `_judge_gq` 를 그대로 쓴다(우선 심판=models[0], 격차=보조 눈) — 우선
# 채택 route 이름만 "qwen_priority" 다. 모델 슬롯은 settings 이 SOT.
OPENROUTER_JUDGE_PREFIX = "openrouter:"
# 합의 정책 버전 — QK ON 시 지문 기여. GQ 와 별도 문자열(같은 수학이라도
# 판정자가 다르면 산출이 다르다 — 정책 식별자를 공유하지 않는다).
# v2 (2026-08-12 밤): 보조=Kimi(OpenRouter)→Gemini 반전 — OpenRouter 지연
# (Kimi 245s·Qwen 207s)+비JSON 50% 실측으로 사용자 재결정. 판정자 구성이
# 바뀌면 산출이 다르므로 정책 버전도 올린다(v1 산출 지문 무효화).
# v3 (2026-08-12 밤, Codex 재확인 BLOCK): Gemini 보조 fallback 봉인
# (enable_fallback=False — safety 강등 시 GPT 가 판정했는데 Gemini 로
# 기록되는 오귀속 차단)은 **실행 의미 변경**이다 — v2(강등 허용) 산출이
# 봉인 계약 산출로 오독되지 않게 정책 버전을 가른다. v2 로 만들어진
# 카나리아 완료 샷은 다음 방문에서 drift/JIT 재검 대상이 되는 것이
# 정직한 계약이다.
QK_SELECT_POLICY_VERSION = (
    "qk_select_v3_qwen_main_gemini_assist_no_fallback_margin020")

# ── G+G46 판정 체계 (2026-08-13 사용자 확정 "Gemini + Grok 4.6 을 50:50
# 으로") — 선정·fix 재판정 심판 = Gemini + grok-4.6(OpenRouter) **동시·
# 동등**. 슬롯 계약과 합의 수학은 `_judge_gq` 재활용하되 **우선권 갈래가
# 없다**: winner 일치=채택(route=agree) / 불일치=`combine_select_verdicts`
# 합산(route=combined — 격차 크기와 무관, gap 은 기록만). 동등 체계에서
# 격차 문턱의 "우선 채택"은 정의될 수 없어(우선 심판이 없다) 불일치 관례
# (combined 합산)를 전 구간에 쓴다. models[0]=Gemini 는 우선권이 아니라
# agree 시 verdict shape 제공 슬롯이다. 근거=23샷 파일럿(단독 판정 정·역
# split 이 셋 다 큼: gemini 5/23·grok 7/23 — 단독 우선권을 줄 근거 부재,
# artifact/grok46judge23).
GG46_SELECT_POLICY_VERSION = (
    # v2 (2026-08-29 사용자 지시): 선정 둘째 심판 grok-4.6 → **GPT-5.6
    # Sol**. 수정 관찰은 Gemini+Grok 그대로다.
    #
    # v3 (2026-08-29, 같은 날): **합산 계약이 바뀌었다** — ①승자가 같아도
    # 합산을 태워 둘째 슬롯의 하드위반이 top-level 에 실린다 ②페널티가
    # 「위반 문장 수」에서 **후보당 한 번**으로 바뀐다. 둘 다 어느 롤이
    # 뽑히는지를 바꿀 수 있으므로 v2 산출 지문을 무효화한다.
    # ★접두사에서도 모델 이름을 뺀다 — `gsol_` 의 `sol` 이 그대로 모델
    #  이름이었다(내 시험이 잡았다).
    "select_v3_equal_always_combine_binary_penalty_no_fallback")

# ★2026-08-29 사용자 지시 — "2, 6번은 Gemini 정순, Grok 역순 으로 병렬로
#  (즉 두번만)". 종전 2모델 × 2순서 = 4콜 순차를 **2콜 병렬**로 줄인다.
#
# ## 무엇을 잃는가 — 정직하게
#
# 모델과 순서가 **엮인다.** 종전에는 모델마다 정·역을 다 봐서 「이 모델이
# 위치에 흔들리나」를 모델별로 쟀는데, 이제 그 축은 **못 잰다.** 두 슬롯이
# 갈렸을 때 모델 차이인지 순서 차이인지도 못 가른다. 2콜 계약의 본질적
# 대가이고, 이걸 보완한다고 세 번째 콜을 몰래 넣지 않는다.
#
# ## 무엇을 지키는가
#
# GG46 의 **모델별 점수 정규화 + 하드위반 합집합**은 그대로다. 역순 응답을
# canonical 라벨로 **먼저 되돌린 뒤**에는 두 모델 결과가 같은 후보 축에
# 놓이므로 `combine_select_verdicts` 를 그대로 쓸 수 있다.
#
# ★옛 `combine_flip_verdicts.agreement` 는 **재사용하지 않는다.** 그 값은
#  「같은 모델이 순서에 안 흔들렸다」였고 지금은 뜻이 다르다. 이름을 물려
#  쓰면 기록이 조용히 거짓말을 한다.
# ★v2 (2026-08-29 Codex BLOCK): 문자열에서 **모델 이름을 뺀다.** v1 은
#  `gemini_fwd_grok_rev` 였는데 둘째 슬롯이 GPT 로 바뀌었다 — 그러면 이
#  값이 실린 durable 기록(`select_order_policy`, still·배경판 지문)이
#  「grok 이 역순을 봤다」는 **거짓**을 말한다. route 이름에서 모델을 뺀
#  것과 같은 이유다.
#
#  이 문자열이 말해야 하는 것은 **순서 알고리즘**이다 — 슬롯0 정순 ·
#  슬롯1 역순 · 2콜 병렬. 어느 모델이 어느 슬롯에 앉았는지는
#  `select_judge_models_physical`(물리 쌍)과 `slots[].model` 이 따로
#  적는다. 그래서 판정 쌍이 또 바뀌어도 이 값은 안 움직인다.
CROSS_MODEL_ORDER_POLICY_VERSION = (
    "cross_model_order_v2_slot0_fwd_slot1_rev_parallel2")


def _normalized_margin(res: Dict[str, Any]) -> float:
    """판정 결과의 1위·2위 정규화 점수 차. 후보가 하나면 1.0(=둘째 불필요)."""
    scores = sorted(
        ((v.get("score") or 0) for v in (res.get("verdicts") or [])),
        reverse=True)
    if len(scores) < 2:
        return 1.0
    top = scores[0] or 1
    return (scores[0] - scores[1]) / top


def resolve_select_judge_models() -> List[str]:
    """후보 선정 판정 모델 목록 — 키가 있으면 이중, 없으면 기존 단독.

    G+Q ON 이면 [Gemini, Qwen] — 첫째가 우선 심판이다(`_judge_gq` 계약).
    ON+DASHSCOPE 키 없음 = fail-closed: 조용히 Gemini 단독으로 강등하면
    "이중으로 판정했다"는 기록·지문이 거짓이 된다. 지문 계산도 이 함수를
    지나므로 잘못된 구성은 지문 단계에서 이미 막힌다.
    """
    from app.core.config import settings

    _on_flags = [
        name for name, on in (
            ("multiroll_qk_judge_enabled",
             settings.multiroll_qk_judge_enabled),
            ("multiroll_gq_judge_enabled",
             settings.multiroll_gq_judge_enabled),
            ("multiroll_gg46_judge_enabled",
             getattr(settings, "multiroll_gg46_judge_enabled", False)),
        ) if on
    ]
    if len(_on_flags) > 1:
        from app.core.errors import AppError

        raise AppError(
            code="multiroll.judge_flags_conflict",
            message=(
                f"{' 와 '.join(_on_flags)} 가 동시에 ON 이다 — 판정 체계는 "
                "하나만. 한쪽 플래그를 내려라 (fail-closed)"
            ),
            status_code=422,
        )
    if settings.multiroll_qk_judge_enabled:
        # 2026-08-12 카나리아 실측 후 사용자 재결정("gemini 를 보조로 하고
        # 기존 코드에서 QWEN 의 비중을 반대로"): OpenRouter 경유 판정이
        # Kimi 평균 245s·Qwen 207s+비JSON 50% 로 운영 부적합 —
        # **메인=Qwen(DashScope 직결, 완주 실측 검증)** +
        # **보조=Gemini(기존 G+Q 의 우선 자리 모델을 보조 자리로 반전)**.
        # 기존 부품 그대로 재활용: _judge_gq 는 슬롯 순서 계약(models[0]=
        # 우선), 두 모델의 호출 경로 모두 기존 센티널/alias. Kimi 보조는
        # 보류(OpenRouter 클라이언트·디스패치는 남겨 둠 — 재도입 시 지연
        # 진단 선행).
        from app.core.errors import AppError
        from app.modules.llm.qwen_vlm_client import qwen_configured

        if not qwen_configured():
            raise AppError(
                code="multiroll.qk_judge_unconfigured",
                message=(
                    "multiroll_qk_judge_enabled=ON 인데 DASHSCOPE_API_KEY 가 "
                    "비어 있다 — Qwen 메인 판정을 돌 수 없다. 키를 넣거나 "
                    "플래그를 내려라 (fail-closed)"
                ),
                status_code=422,
            )
        # 물리 모델 슬롯 공백 preflight (Codex 반전 리뷰 BLOCK-1):
        # qwen_configured 는 키만 본다 — qwen_vlm_model 이 공백이면 빈
        # model 호출이 실패하고 _judge_gq 가 그것을 삼켜 Gemini 단독
        # (single_*)으로 확정된다(이중 설정인데 단독 판정 — 이전 OpenRouter
        # 빈 슬롯 BLOCK 과 같은 부류). 첫 유료 호출 전에 막는다.
        if not (settings.qwen_vlm_model or "").strip() \
                or not (settings.gemini_text_model or "").strip():
            raise AppError(
                code="multiroll.qk_judge_empty_model_slot",
                message=(
                    "qwen_vlm_model/gemini_text_model 중 빈 슬롯이 있다 — "
                    "QK 이중 판정이 성립하지 않는다. 물리 모델 설정을 "
                    "채워라 (fail-closed)"
                ),
                status_code=422,
            )
        return [QWEN_JUDGE_MODEL, JUDGE_MODEL]
    if settings.multiroll_gq_judge_enabled:
        from app.core.errors import AppError
        from app.modules.llm.qwen_vlm_client import qwen_configured

        if not qwen_configured():
            raise AppError(
                code="multiroll.gq_judge_unconfigured",
                message=(
                    "multiroll_gq_judge_enabled=ON 인데 DASHSCOPE_API_KEY 가 "
                    "비어 있다 — Qwen 없이 G+Q 판정을 돌 수 없다. 키를 "
                    "넣거나 플래그를 내려라 (fail-closed)"
                ),
                status_code=422,
            )
        return [JUDGE_MODEL, QWEN_JUDGE_MODEL]
    if getattr(settings, "multiroll_gg46_judge_enabled", False):
        # ── 2026-08-29 사용자 지시 — **고르는 자리와 결함 찾는 자리를
        #    가른다.** "평가는 Gemini 3.1 pro + GPT sol 로 하자 대신
        #    수정사항 부분은 그대로 gemini + grok 으로"
        #
        #    선정(여기)  = Gemini 정순 + **GPT-5.6 Sol** 역순
        #    수정 관찰    = Gemini + Grok  (`make_gg46_critique_fn` 이
        #                  `grok_judge_model` 로 자기 목록을 따로 만든다 —
        #                  이 함수의 반환값을 안 읽는다)
        #
        #    ★여기서 **OpenRouter/grok 은 확인하지 않는다** (Codex BLOCK-1).
        #    선정은 그 열쇠를 안 쓴다. 안 쓰는 것을 요구하면 정상 구성이
        #    422 로 막힌다. grok 확인은 그것을 실제로 쓰는 자리
        #    (`make_gg46_critique_fn`)로 옮겼다 — preflight 는 소비처 옆에.
        #
        #    ★둘째 심판을 .env 자유 문자열로 만들지 않았다. 쌍이 바뀌면
        #    정책 문자열(GG46_SELECT_POLICY_VERSION)도 같이 움직여야 하는데,
        #    설정으로 갈아 끼우면 정책은 그대로인 채 판정자만 바뀌어
        #    **기록이 「이 정책으로 골랐다」는 거짓**을 말하게 된다.
        from app.core.errors import AppError
        from app.core.openai_keys import has_openai_key

        # ★`settings.openai_api_key` 를 직접 보지 않는다 (Codex BLOCK-1).
        #  그건 **1차 슬롯 필드**라, 보조 슬롯·환경변수만 있는 정상 구성을
        #  「키 없음」으로 막는다. `openai_keys.py:486` 이 그 결함을 고치려고
        #  세운 단일 권위다.
        if not has_openai_key():
            raise AppError(
                code="multiroll.select_judge_unconfigured",
                message=(
                    "선정 판정 둘째 슬롯이 GPT 인데 OpenAI 키가 하나도 "
                    "없다 — 키가 없으면 alias 가 Router 에 안 붙어 조용히 "
                    "단독 판정이 된다. 키를 넣거나 플래그를 내려라 "
                    "(fail-closed)"
                ),
                status_code=422,
            )
        if not (settings.gemini_text_model or "").strip() \
                or not (settings.openai_model or "").strip():
            raise AppError(
                code="multiroll.gg46_judge_empty_model_slot",
                message=(
                    "gemini_text_model/openai_model 중 빈 슬롯이 있다 — "
                    "선정 동등 이중 판정이 성립하지 않는다. 물리 모델 "
                    "설정을 채워라 (fail-closed)"
                ),
                status_code=422,
            )
        return [JUDGE_MODEL, SELECT_JUDGE_MODEL_2]
    if not settings.anthropic_api_key:
        return [JUDGE_MODEL]
    if not settings.multiroll_dual_select_judge_enabled:
        return [SELECT_JUDGE_MODEL]
    return [SELECT_JUDGE_MODEL, SELECT_JUDGE_MODEL_2]


# 선정 판정이 어느 갈래로 가는가 — **한 자리에서만 정한다** (2026-08-29).
#
# 종전에는 `make_gemini_judge_fn` 안에서 세 군데가 각자 목록 모양을 뜯어
# 봤다: fallback 봉인 · owns_order · dispatch 의 if 사슬 —
# dispatch 의 if 사슬. 셋이 **접두사 `openrouter:`** 를 단서로 삼았는데,
# 그건 「동등 교차쌍이다」가 아니라 「둘째가 OpenRouter 를 탄다」일 뿐이다.
# 둘째를 GPT 로 옮기는 순간 셋 다 조용히 어긋나 조건부 이중(margin-skip)
# 으로 떨어진다 — 오류는 안 나고 계약만 바뀐다.
#
# 그래서 **목록만 보고 갈래를 정하는 순수 함수** 하나로 모은다. 세 자리가
# 같은 함수에 같은 입력을 넣으므로 갈릴 수가 없다.
PAIR_QK_REVERSED = "qk_reversed"          # Qwen 메인 + Gemini 보조
PAIR_GQ = "gq"                            # Gemini 우선 + Qwen 보조
PAIR_EQUAL_CROSS = "equal_cross"          # Gemini 정순 + 둘째 역순, 2콜
PAIR_OPENROUTER_PRIORITY = "openrouter_priority"   # (잔존) QK 수학 공유
PAIR_DUAL_MARGIN = "dual_margin"          # 조건부 이중 (격차 크면 둘째 생략)
PAIR_SINGLE = "single"


def select_judge_pair_kind(models: Sequence[str]) -> str:
    """선정 판정 갈래 — dispatch·owns_order·fallback 봉인의 **단일 출처**."""
    ms = list(models)
    if len(ms) < 2:
        return PAIR_SINGLE
    if ms[0] == QWEN_JUDGE_MODEL:
        return PAIR_QK_REVERSED
    if QWEN_JUDGE_MODEL in ms:
        return PAIR_GQ
    if len(ms) == 2 and ms[0] == JUDGE_MODEL and ms[1] != JUDGE_MODEL:
        # Gemini 앞 + 서로 다른 둘째 = 동등 교차쌍. 둘째가 OpenRouter 든
        # Router alias(GPT)든 계약은 같다 — 우선권 없음, 정순/역순 2콜.
        return PAIR_EQUAL_CROSS
    if any(m.startswith(OPENROUTER_JUDGE_PREFIX) for m in ms):
        return PAIR_OPENROUTER_PRIORITY
    return PAIR_DUAL_MARGIN


def _q_gap(q_res: Dict[str, Any], g_winner: str, q_winner: str) -> float:
    """"Qwen 눈의 격차" — Qwen 정규화 점수에서 Q 승자 − G 승자.

    Gemini 우선 체계라 재는 것은 "Qwen 이 Gemini 승자에 얼마나 반대하는가"
    하나뿐이다. 전 후보 0점(top<=0)이면 격차를 잴 근거가 없다 — 0.0(=반대
    근거 없음)으로 떨어져 Gemini 채택이 된다.
    """
    scores = {v.get("label"): (v.get("score") or 0)
              for v in (q_res.get("verdicts") or [])}
    top = max(scores.values()) if scores else 0
    if top <= 0:
        return 0.0
    return (scores.get(q_winner, 0) - scores.get(g_winner, 0)) / top


def _judge_gq(models: List[str], one, parts, labels: List[str],
              priority_route: str = "gemini_priority",
              equal_disagree_combined: bool = False) -> Dict[str, Any]:
    """우선+보조 동시 판정 — 2026-08-10 합의 규칙. margin-skip 없이 둘 다 부른다.

    슬롯 계약: models[0]=우선(취합) 심판, models[1]=보조(관찰·교차) 심판.
    · winner 일치 → 우선 판정 채택 (route=agree)
    · 불일치 & 격차<`GQ_DISAGREE_MARGIN` → 우선 채택 (route=priority_route)
    · 불일치 & 격차≥문턱 → `combine_select_verdicts` 합산 (route=combined)
    · 한쪽 실패 → 남은 쪽 채택+경고 (route=single_<모델>) / 전건 실패 → raise

    priority_route (2026-08-12 QK 전환): 우선 채택 route 의 기록 이름 —
    G+Q(기본)="gemini_priority" byte-identical, QK="qwen_priority".

    equal_disagree_combined (2026-08-13 G+G46): True 면 **동등 이중** —
    우선 채택 갈래를 걷어내고 불일치는 격차와 무관하게 전부 combined
    합산이다(동등 체계에는 격차 문턱이 정할 "우선 심판"이 없다). 이때
    models[0] 은 우선권이 아니라 agree 시 verdict shape 제공 슬롯이고,
    gap 은 기록으로만 남는다. False(default)=기존 수학 byte-identical.

    반환 shape 는 단독 판정 호환(winner/ranking/verdicts…) — 채택 경로와
    양쪽 승자·격차는 `gq` 키에 병기해 record·갤러리가 추적한다 (키 이름은
    체계 무관 공용 기록 슬롯이다).
    """
    out: Dict[str, Dict[str, Any]] = {}
    last_exc: Optional[BaseException] = None

    # ★두 심판을 **동시에** 부른다 (2026-08-29 사용자 지시 "2, 4, 8 병렬로").
    #  둘은 같은 `parts`(지시문 + 참조 + 후보들)를 각자 보고 각자 답할 뿐
    #  서로를 안 본다 — 합산(`combine_select_verdicts`)은 이 아래에서 한다.
    #
    # ★결과는 **완료 순서가 아니라 `models` 슬롯 순서**로 모은다. 슬롯 순서가
    #  우선권 계약이고(`models[0]`=우선·취합) `per_model_winner` 기록도 그
    #  순서를 따른다 — 흔들리면 어느 모델이 무엇을 말했나가 호출마다 달라진다.
    #
    # ★한쪽 실패는 종전과 같이 `single_*` route 로 살아남는다. 예외를 슬롯
    #  순서로 훑어야 `last_exc` 도 결정적이다.
    #
    # ★입력·모델·결합 순서가 같으므로 **지문은 안 움직인다** — 병렬 여부는
    #  산출 계약이 아니다.
    from concurrent.futures import ThreadPoolExecutor

    from app.core.image_call_budget import bind_current_budget
    from app.core.send_ledger import bind_current_ledger
    from app.modules.llm.opik_trace import bind_current_trace
    from app.services.image_capture.context import (
        bind_current_generation_context,
    )

    def _call(m: str) -> Dict[str, Any]:
        return one(m, parts, f"_{m.replace('-', '')}")

    # ★**발송 장부도 나른다** (2026-09-20 Codex BLOCK). 안 실으면 이
    #  pool 안의 판정 호출이 통째로 안 잡혀 **「관측 0」**이 된다 —
    #  롤 팬아웃만 실어서는 **판정 pool 에 전파되지 않는다**.
    _bound = bind_current_budget(
        bind_current_ledger(
            bind_current_generation_context(bind_current_trace(_call))))
    with ThreadPoolExecutor(max_workers=len(models)) as _pool:
        _futs = {m: _pool.submit(_bound, m) for m in models}
    for m in models:
        try:
            out[m] = _futs[m].result()
        except Exception as exc:  # noqa: BLE001
            last_exc = exc
            logger.warning(
                "G+Q 판정: %s 실패 — 남은 심판으로 진행: %r", m, exc)
    if not out:
        raise RuntimeError("G+Q 선정 판정 전건 실패") from last_exc
    if len(out) == 1:
        m, res = next(iter(out.items()))
        return {**res, "gq": {"route": f"single_{m}", "models": [m]}}
    g_model, q_model = models[0], models[1]
    g, q = out[g_model], out[q_model]
    gw, qw = g.get("winner"), q.get("winner")
    winners = {g_model: gw, q_model: qw}
    if gw == qw:
        return {**g, "gq": {"route": "agree", "gap": 0.0,
                            "per_model_winner": winners,
                            "models": list(out)}}
    gap = _q_gap(q, str(gw), str(qw))
    if not equal_disagree_combined and gap < GQ_DISAGREE_MARGIN:
        return {**g, "gq": {"route": priority_route, "gap": round(gap, 3),
                            "per_model_winner": winners,
                            "models": list(out)}}
    combined = combine_select_verdicts(out, labels)
    if equal_disagree_combined:
        # (Codex GG46 R1 BLOCK-1) combined 의 실소비자는 winner 필드가
        # 아니라 **verdicts 점수**다 — 비flip 경로는 gemini_select 가
        # verdicts 최고점으로 다시 고르고, flip 경로(judge_flip·fix
        # 재판정)는 combine_flip_verdicts 가 verdicts 점수를 합산한다.
        # 기존 shape 는 첫 심판(Gemini) 원점수를 실어, 동등 합산 승자가
        # 하류에서 조용히 Gemini 우선으로 되돌아갔다(50:50 계약 불성립).
        # 동등 모드만 verdicts 점수를 합산값(adjusted×1000 — shape
        # 검증기가 int 요구)으로 교체해 소비자가 합산 승자를 그대로
        # 뽑게 한다. 동점 미만 차이는 ranking(합산 순)이 가른다.
        # GQ/QK combined 는 기존 shape 그대로(byte-identical).
        _adj = (combined.get("dual") or {}).get("adjusted") or {}
        combined["verdicts"] = [
            {**v, "score": int(round(_adj.get(v.get("label"), 0) * 1000))}
            for v in (combined.get("verdicts") or [])
        ]
    combined["gq"] = {"route": "combined", "gap": round(gap, 3),
                      "per_model_winner": winners, "models": list(out)}
    return combined


def _judge_cross_model_order(
    models: List[str], one, head_text: str, labeled_refs,
    cand_paths, labels: List[str],
) -> Dict[str, Any]:
    """★Gemini 정순 · 둘째 심판 역순 — **2콜 병렬** (2026-08-29).

    > "2, 6번은 Gemini 정순, Grok 역순 으로 병렬로 (즉 두번만)"

    ## 하는 일

        슬롯0(Gemini) ← 후보를 **정순**으로 제시
        슬롯1(둘째)   ← 후보를 **역순**으로 제시      ← 둘을 동시에
        역순 응답을 canonical 라벨로 **역매핑**
        두 canonical 결과를 GG46 규칙으로 결합

    ## 지키는 것

    역순 응답을 canonical 로 되돌린 **뒤에는** 두 모델 결과가 같은 후보 축에
    놓인다. 그래서 `combine_select_verdicts` 의 **모델별 최고점 정규화**와
    **하드위반 합집합**이 그대로 돈다 — 척도가 다른 두 모델을 원점수로 더하면
    큰 척도가 판정을 지배하고, 위반을 교집합으로 세면 「한쪽이 못 보는 축을
    다른 쪽이 채운다」는 이중의 목적이 사라진다.

    한쪽 실패는 생존 슬롯으로 진행(`single_forward` / `single_reverse`
    — 모델 이름은 route 에 안 박는다, `slots[].model` 이 SOT), 양쪽
    실패는 raise — 기존 계약 그대로다.

    ## ★잃는 것 — 숨기지 않는다

    모델과 순서가 **엮인다.** 「이 모델이 위치에 흔들리나」는 더 못 잰다.
    두 슬롯이 갈렸을 때 모델 차이인지 순서 차이인지도 못 가른다. 2콜
    계약의 본질적 대가이고 세 번째 콜을 몰래 넣어 보완하지 않는다.

    그래서 기록에 **`agreement` 라는 이름을 쓰지 않는다.** 옛 flip 의 그
    값은 「같은 모델이 순서에 안 흔들렸다」였다. 여기서는
    `slot_winner_match` — 「모델도 순서도 다른 두 슬롯의 승자가 같았다」일
    뿐이고, 그 뜻을 키 이름과 정책 문자열이 말한다.
    """
    from concurrent.futures import ThreadPoolExecutor

    from app.core.image_call_budget import bind_current_budget
    from app.core.send_ledger import bind_current_ledger
    from app.modules.llm.opik_trace import bind_current_trace
    from app.modules.pipeline.multiroll_select import (
        flip_display_to_canonical, normalize_flip_verdict,
    )
    from app.services.image_capture.context import (
        bind_current_generation_context,
    )

    g_model, x_model = models[0], models[1]
    rev_labels = list(reversed(labels))

    def _parts(order: List[str]) -> List[Dict[str, Any]]:
        """제시 순서만 다른 같은 payload — 참조·지시문은 공유."""
        out: List[Dict[str, Any]] = [{"type": "text", "text": head_text}]
        out += ref_parts(labeled_refs)
        by_label = dict(zip(labels, cand_paths))
        # display 라벨은 늘 A,B… — 어느 후보가 어느 자리에 오는지만 바뀐다
        for disp, src in zip(labels, order):
            out.append({"type": "text", "text": f"Candidate {disp}:"})
            out.append(png_part(by_label[src]))
        return out

    slots = {
        g_model: ("forward", labels, {lab: lab for lab in labels}),
        x_model: ("reverse", rev_labels, flip_display_to_canonical(labels)),
    }

    def _call(model: str) -> Dict[str, Any]:
        _, order, _ = slots[model]
        return one(model, _parts(order), f"_{model.replace('-', '')}")

    # ★**발송 장부도 나른다** (2026-09-20 Codex BLOCK). 안 실으면 이
    #  pool 안의 판정 호출이 통째로 안 잡혀 **「관측 0」**이 된다 —
    #  롤 팬아웃만 실어서는 **판정 pool 에 전파되지 않는다**.
    _bound = bind_current_budget(
        bind_current_ledger(
            bind_current_generation_context(bind_current_trace(_call))))
    with ThreadPoolExecutor(max_workers=2) as pool:
        futs = {m: pool.submit(_bound, m) for m in (g_model, x_model)}

    canon: Dict[str, Dict[str, Any]] = {}
    raw: Dict[str, Any] = {}
    failed: List[str] = []
    last_exc: Optional[BaseException] = None
    # ★슬롯 순서로 훑는다 — 완료 순서로 모으면 기록이 호출마다 흔들린다.
    for model in (g_model, x_model):
        order_name, _, d2c = slots[model]
        try:
            res = futs[model].result()
            raw[model] = res
            # ★역매핑을 **먼저** 한다. 그래야 두 결과가 같은 후보 축이 되고
            #  GG46 합산이 의미를 갖는다.
            canon[model] = normalize_flip_verdict(res, d2c, labels)
        except Exception as exc:  # noqa: BLE001
            last_exc = exc
            failed.append(model)
            logger.warning(
                "cross-model 판정: %s(%s) 실패 — 남은 슬롯으로 진행: %r",
                model, order_name, exc)

    if not canon:
        raise RuntimeError("cross-model 선정 판정 전건 실패") from last_exc

    def _slot_record() -> List[Dict[str, Any]]:
        out = []
        for model in (g_model, x_model):
            order_name, order, d2c = slots[model]
            out.append({
                "model": model,
                "order": order_name,
                "display_to_canonical": d2c,
                "raw": raw.get(model),
                "normalized": canon.get(model),
                "ok": model in canon,
            })
        return out

    base = {
        "policy": CROSS_MODEL_ORDER_POLICY_VERSION,
        "slots": _slot_record(),
        "models": [g_model, x_model],
    }
    if failed:
        base["failed"] = failed

    if len(canon) == 1:
        model, res = next(iter(canon.items()))
        order_name = slots[model][0]
        # ★모델 이름을 박지 않는다 — 둘째 슬롯은 grok 에서 GPT 로 바뀌었고
        #  또 바뀔 수 있다. 「grok 이 골랐다」고 적힌 기록이 실제로는 GPT
        #  였으면 그건 결함보다 나쁜 거짓말이다. 어느 모델이었는지는
        #  `slots[].model` 에 이미 실려 있다.
        route = (f"single_{order_name}"
                 if model in (g_model, x_model) else "single_unknown")
        return {**res, "cross_model_order": {**base, "route": route}}

    gw = canon[g_model].get("winner")
    xw = canon[x_model].get("winner")
    # ★`agreement` 라 부르지 않는다 — 모델도 순서도 다른 두 슬롯의 승자가
    #  같았다는 뜻일 뿐이다.
    base["slot_winner_match"] = gw == xw
    base["slot_winner"] = {g_model: gw, x_model: xw}

    # ★두 슬롯이 다 살아 있으면 **승자가 같아도 합산을 태운다**
    #  (2026-08-29 Codex 합의).
    #
    #  종전에는 `gw == xw` 면 `canon[g_model]` 을 그대로 돌려줬다. 그러면
    #  **둘째 슬롯이 찾은 하드위반이 top-level 에 안 실린다** — 슬롯 기록
    #  안에만 남고 선정 합산·`all_candidates_fail`·durable 감사에 안 닿는다.
    #  둘째 심판을 세운 목적이 **지배적인 갈래에서** 사라졌다.
    #  실측(2026-08-29 최소 주행): 6샷 중 agree 5샷, 그중 S1sh4 에서
    #  `{gemini:1, gpt:2}` → top-level 1건. 2건이 버려졌다.
    #
    #  ★그래서 route 이름도 승자로만 정하지 않는다 — 두 raw 승자가 같고
    #   **합산 승자까지 같을 때만** `agree` 다. 페널티 때문에 최종 승자가
    #   달라지면 그건 합의가 아니므로 `combined` 로 적는다.
    combined = combine_select_verdicts(canon, labels)
    # 동등 합산 — 소비처가 winner 가 아니라 **verdicts 점수**를 본다
    # (GG46 BLOCK-1 관례 그대로: 첫 심판 원점수를 실으면 하류가 조용히
    #  Gemini 우선으로 되돌아간다).
    _adj = (combined.get("dual") or {}).get("adjusted") or {}
    combined["verdicts"] = [
        {**v, "score": int(round(_adj.get(v.get("label"), 0) * 1000))}
        for v in (combined.get("verdicts") or [])
    ]
    route = ("cross_slot_agree"
             if (gw == xw and combined.get("winner") == gw)
             else "cross_slot_combined")
    combined["cross_model_order"] = {**base, "route": route}
    return combined


def combine_select_verdicts(
    per_model: Dict[str, Dict[str, Any]], labels: List[str],
) -> Dict[str, Any]:
    """이중 판정 합의 — 하드 위반 개수 페널티 + 모델별 정규화 점수 합.

    점수를 그냥 더하면 안 된다. 척도가 모델마다 다르다(0~100 대 0~10) — 원
    점수를 더하면 큰 척도 모델이 판정을 지배한다. 이 프로젝트에서 이미 겪은
    함정이라 모델별 최고점으로 정규화한 뒤 더한다.

    하드 위반은 **합집합**으로 센다. 한쪽이 못 보는 축을 다른 쪽이 채우는 것이
    이중의 목적이므로 교집합을 쓰면 그 목적이 사라진다.

    반환 shape 는 단독 판정과 호환된다(winner/ranking/verdicts) — 호출측
    코드와 기록 계약을 그대로 둔다.
    """
    norm: Dict[str, float] = {lab: 0.0 for lab in labels}
    vio: Dict[str, List[str]] = {}
    n_fail = 0
    for model, res in per_model.items():
        readings = res.get("readings") or []
        verdicts = res.get("verdicts") or []
        if res.get("all_candidates_fail"):
            n_fail += 1
        top = max((v.get("score") or 0) for v in verdicts) if verdicts else 0
        for v in verdicts:
            lab = v.get("label")
            if lab in norm:
                norm[lab] += (v.get("score") or 0) / (top or 1)
        for r in readings:
            lab = r.get("label")
            for hv in r.get("hard_violations") or []:
                if lab in norm:
                    vio.setdefault(lab, []).append(f"[{model}] {hv}")
    # ★페널티는 **후보당 한 번**이다 (2026-08-29 Codex 합의).
    #
    #  종전에는 `len(vio[lab])` 이라 **보고 문장 수가 곧 페널티 크기**였다.
    #  같은 결함을 두 심판이 말하면 두 번 깎였고, 한 심판이 장황하면 그
    #  후보만 더 깎였다. 그건 결함의 크기가 아니라 **말수**다.
    #
    #  ★문자열로 「같은 결함인가」를 판정하지 않는다 — 이 저장소에서 의미
    #   판단은 LLM/VLM 만 한다. LLM 을 한 콜 더 부르는 것은 시간 축에서
    #   반대다. 그래서 dedupe 대신 **이진**으로 바꾼다: 그 후보에 하드
    #   위반이 하나라도 있으면 한 번, 없으면 0.
    #
    #  원문 위반은 모델 provenance 를 달아 `vio` 에 **전부 남는다** —
    #  줄이는 것은 점수 계산뿐이고 기록은 안 줄인다.
    #  ★이건 「품질 개선」이 아니라 **기록·계산 정합 수정**이다. 「하드면
    #   무조건 탈락」으로 바꾸는 것은 false-positive pilot 뒤 별도 판단이다.
    adj = {lab: norm[lab] - (SELECT_VIOLATION_PENALTY
                             if vio.get(lab) else 0.0)
           for lab in labels}
    ranking = sorted(labels, key=lambda lab: -adj[lab])
    # verdicts 는 첫 심판 것을 싣되, 위반 합집합을 판정문에 덧붙여 기록이
    # 두 심판을 모두 대변하게 한다.
    first = next(iter(per_model.values()))
    verdicts = []
    for v in (first.get("verdicts") or []):
        lab = v.get("label")
        extra = vio.get(lab) or []
        verdicts.append({
            **v,
            "verdict_ko": (v.get("verdict_ko", "")
                           + ("  ★위반: " + " / ".join(extra) if extra else "")),
        })
    # ★`readings` 를 돌려준다 (2026-08-29 Codex 합의). 종전에는 이 키가
    #  **아예 없어서**, 합산 갈래를 탄 샷은 durable 기록의 구조화 하드위반이
    #  통째로 0 이 됐다. 실측: 이번 주행에서 슬롯이 낸 9건 중 3건이 top-level
    #  에 안 실렸다.
    #
    #  서술 칸(direction/built_space/entities/physics)은 **첫 슬롯 것**을
    #  호환용으로 싣는다 — 양쪽 원문은 `cross_model_order.slots[].normalized`
    #  가 SOT 다. `hard_violations` 만 양 슬롯 것을 provenance 와 함께 모은다.
    #  ★행을 **canonical 라벨로** 돈다 (2026-08-29 Codex BLOCK-1).
    #   종전에는 첫 슬롯의 행만 훑었다. 그런데 스키마는 `readings` 배열이
    #   있기만 하면 되고 **후보마다 한 행**을 강제하지 않으며
    #   (`build_judge_schema`), `_validate_judge_shape` 는 `readings` 를
    #   **아예 안 본다.** 그래서 슬롯0 이 B 행을 빼먹고 슬롯1 에만 B 위반이
    #   있는 **유효한** 응답이면, 점수와 `dual.violations` 에는 B 가 들어가는데
    #   top-level 에는 **B 행 자체가 없어** 이 판이 닫으려던 유실이 남는다.
    #   서술은 **그 라벨을 말한 첫 슬롯** 것을 쓰고, 없으면 다음 슬롯으로
    #   내려간다. 위반은 언제나 합친 `vio[lab]` 이다.
    rows_by_label: Dict[str, List[Dict[str, Any]]] = {}
    for _m, _res in per_model.items():
        for r in (_res.get("readings") or []):
            rows_by_label.setdefault(str(r.get("label")), []).append(r)
    readings = []
    for lab in labels:
        rows = rows_by_label.get(lab) or []
        hv = list(vio.get(lab) or [])
        if not rows and not hv:
            continue          # 아무 슬롯도 말하지 않았고 위반도 없다
        base_row = dict(rows[0]) if rows else {}
        readings.append({**base_row, "label": lab, "hard_violations": hv})
    return {
        "winner": ranking[0],
        "ranking": ranking,
        "verdicts": verdicts,
        "readings": readings,
        # ★심판 **전원**이 선언했을 때만. 처음엔 `n_fail*2 >= n` 으로 썼는데
        #  2명 중 1명만 선언해도 참이 되어(절반 이상 ≠ 과반) 254샷에서
        #  45%(115건)가 "후보 전부 실패"로 찍혔다 — 단독 판정에서는 2% 였다.
        #  둘째 심판이 하드 위반을 남발하는 성향과 겹쳐 신호가 무의미해졌다.
        "all_candidates_fail": bool(per_model) and n_fail == len(per_model),
        "dual": {
            "models": list(per_model),
            "normalized": {k: round(v, 3) for k, v in norm.items()},
            "adjusted": {k: round(v, 3) for k, v in adj.items()},
            "violations": vio,
            "per_model_winner": {m: r.get("winner")
                                 for m, r in per_model.items()},
            "agreed": len({r.get("winner") for r in per_model.values()}) == 1,
        },
    }


def _judge_dual(models: List[str], one, parts, labels: List[str],
                ) -> Dict[str, Any]:
    """조건부 이중 판정.

    첫 심판을 먼저 부르고, 그가 1위를 `SELECT_DUAL_MARGIN_SKIP` 이상으로
    앞세우면 둘째를 부르지 않는다 — 그 구간에서는 둘째가 판정을 거의
    뒤집지 않는다는 것이 208샷 실측이다. 근소하면 둘째를 불러 합의한다.

    한쪽이 죽으면 남은 쪽으로 진행한다. 둘 다 죽으면 원인을 달아 올린다.
    """
    out: Dict[str, Dict[str, Any]] = {}
    last_exc: Optional[BaseException] = None
    for m in models:
        try:
            out[m] = one(m, parts, f"_{m.replace('-', '')}")
        except Exception as exc:  # noqa: BLE001
            last_exc = exc
            logger.warning(
                "이중 판정: %s 실패 — 남은 심판으로 진행: %r", m, exc)
            continue
        if len(out) == 1 and len(models) > 1:
            margin = _normalized_margin(out[m])
            if margin >= SELECT_DUAL_MARGIN_SKIP:
                logger.info(
                    "선정 판정: %s 가 1위를 %.2f 앞세워 둘째 심판 생략",
                    m, margin)
                return out[m]
    if not out:
        # 원인을 삼키지 않는다 — 전건 실패의 진짜 이유(키·이미지 형식·쿼터)를
        # 여기서 잃으면 호출측이 무엇을 고쳐야 하는지 알 수 없다.
        raise RuntimeError("이중 선정 판정 전건 실패") from last_exc
    if len(out) == 1:
        return next(iter(out.values()))
    return combine_select_verdicts(out, labels)


def resolve_select_judge_model() -> str:
    """후보 선정 판정에 쓸 model alias — 키가 없으면 기존 Gemini 로 남는다.

    지문(fingerprint)에도 이 함수의 반환값을 실어야 한다. 판정 모델이 바뀌면
    선정 결과가 바뀌므로 산출이 무효화돼야 한다.
    """
    from app.core.config import settings

    if settings.multiroll_qk_judge_enabled:
        # QK 메인(취합·우선 자리) — Qwen(DashScope) 센티널 (2026-08-12 반전)
        return QWEN_JUDGE_MODEL
    if settings.multiroll_gq_judge_enabled:
        return JUDGE_MODEL
    if getattr(settings, "multiroll_gg46_judge_enabled", False):
        # G+G46 동등 — 우선권이 없어 대표 alias 는 shape 제공 슬롯(Gemini).
        # GQ 와 alias 가 같지만 지문은 gg46_select_policy 스탬프+물리 쌍
        # (resolve_select_judge_model_physical)이 가른다.
        return JUDGE_MODEL
    return SELECT_JUDGE_MODEL if settings.anthropic_api_key else JUDGE_MODEL


def resolve_select_judge_model_physical() -> str:
    """선정 판정의 **물리** 모델 문자열 — 지문 스탬프용.

    alias 는 그대로인 채 뒤의 실제 모델만 바뀌는 경우(판 교체)를 지문이 잡아야
    하므로 alias 와 따로 기록한다. 이중일 때는 두 모델을 모두 싣는다 — 한쪽만
    바뀌어도 어느 롤이 뽑히는지가 바뀐다.
    """
    from app.core.config import settings

    phys = {
        SELECT_JUDGE_MODEL: settings.anthropic_judge_model,
        SELECT_JUDGE_MODEL_2: settings.openai_model,
        JUDGE_MODEL: settings.gemini_text_model,
        QWEN_JUDGE_MODEL: settings.qwen_vlm_model,
    }
    return "+".join(phys.get(m, m) for m in resolve_select_judge_models())
_COUNT_WORDS = {1: "ONE", 2: "TWO", 3: "THREE", 4: "FOUR", 5: "FIVE"}

JUDGE_PACK_VERSION_MAP = {
    "1": "1.202607132300",
    # v2 (E2E6 피드백 ⑥ 근본 대응, 2026-07-16): 판정·결함 계약에 엄격
    # 우선순위 도입 — hard violation > 샷 텍스트 프레이밍·카메라축·크롭·
    # 순간 > 배치·관계 > 화면 내 보이는 범위의 정체성·소품 > pose/carried
    # (visible-within-frame 한정, 프레임 밖 부재 감점·와이드 가점 금지).
    # 실측 근거=S23sh1: 클로즈업 정답 롤 A 를 신체·carried 계약 우선으로
    # 역선택(C7>B4>A3)+critique 가 가방 끈을 그려 넣어 악화.
    # ★judge_plate_ext 는 현재 runtime 소비처 0 (dormant — 세 소비자 전부
    # judge_still 사용, Codex 배치 리뷰 MINOR 명시). v2 의 generic 화는
    # 배선 시 효력.
    "2": "2.202607161610",
    # v3 (2026-07-21 E2E10 fix② Codex HIGH-4): fix_rejudge_header **단일
    # 스템 팩** — [선정 원본 vs i2i 수정본] 2후보 재판정용 중립 헤더.
    # 기본 헤더('all candidates were generated from this')는 수정본이
    # repair prompt 로 생성돼 거짓 — provenance 비노출 중립 계약으로 교체.
    # 기존 판정 계약(v2)은 무변경.
    "3": "3.202607211600",
    # v4 (2026-07-22 E2E11 fix③): gpt_composition_sys **단일 스템 팩** —
    # GPT 구도 전담 critique 계약. 기존 판정 계약(v2)·재판정 헤더(v3)
    # 무변경.
    "4": "4.202607221130",
    # v5 (2026-07-22 E2E13 fix⑤): v2 전체 스템 사본 + ①critique FIX
    # FEASIBILITY 절 — fix_en 은 현재 카메라·시점을 유지하는 국소 편집만,
    # 시점/카메라 이동·경사 반전이 필요한 결함은 unfixable=true 로 보고
    # (L05B01 실측: 카메라 이동 fix 지시가 두 번째 철문 hard violation 을
    # 생성해 반전 원본이 확정) ②judge_still/judge_plate_ext HARD 축 —
    # 프롬프트 structure facts 의 수직(오르내림) 방향과 반대로 렌더되거나
    # 구조가 제공할 수 없는 vantage(최상층 위)에 선 후보=실격.
    # fix_rejudge_header(v3)·gpt_composition_sys(v4)는 별도 selector 유지.
    "5": "5.202607230000",
    # v6 (2026-08-06 사용자 확정 — 16샷 전수 육안 대조가 근거): v5 사본 +
    # ①judge_still 에 **세 축 서술 강제** — 후보마다 방향(시선·총구가 실제로
    #  무엇에 꽂히는가)·복잡 구조물 내부 공간(조작 장치 개수·좌석 배치·반사의
    #  광학적 가능성)·중요 엔티티 정체(지폐의 국적과 액면, 장신구 형태, 인종)
    #  를 점수 매기기 **전에** 문장으로 쓰게 한다. 점수만 받으면 안 본 축을
    #  안 본 채로 넘어간다 — 총구가 상대를 전혀 겨누지 않은 후보에 A6:B14 를
    #  준 것이 그 결과다. + `hard_violations` 배열과 `all_candidates_fail`.
    # ②critique — severity(critical/major/minor) 도입, "수정은 공짜가 아니다"
    #  실측 열거(발 하나 지우랬더니 시신 의상이 바뀜 등), fix_en 에 **보존
    #  조항을 이름으로** 요구, 금지에는 그 자리를 무엇이 채우는지 함께 쓰게,
    #  소품의 액면·등급이 서사를 지탱할 때 그것을 명시하게.
    "6": "6.202608062200",
    # v7 (2026-08-07 심판 4종 대조가 근거): v6 사본 + 세 갈래.
    # ①judge_still 에 **PHYSICS** 네 번째 서술 축 — 공중에 뜬 몸·물체를
    #  무엇이 받치는지(도움닫기·쥔 손·충격·딛고 선 면) 쓰게 하고, 아무것도
    #  받치지 않으면 하드 위반. 같은 이미지에 Opus·Sol·Qwen 셋이 최고점을
    #  주고 **셋 다 부유를 한 줄도 안 썼는데** Gemini 만 물리 불가로 걸었다.
    #  v6 도 hard 예시 목록엔 적어 두었으나 목록에 있는 것과 보게 만드는
    #  것은 다르다. "샷 텍스트가 공중이라 했다"는 면책이 아니라 지지를
    #  찾아야 할 이유라고 못 박고, 걷는 자세를 눕힌 것은 도약이 아니라고 명시.
    # ②fix_rejudge_header 에 **수정 범위** 축 — 지시하지 않은 변경을 손실로
    #  세게 하고 대등하면 덜 바꾼 쪽. 개악 실측 4건을 근거로 적었다.
    # ③fix_ref_label 신규 — 편집 호출에 동봉하는 참조의 라벨(편집 대상 아님).
    "7": "7.202608071100",
    # v8 (2026-08-10 G+Q 확정): **추가-스템 전용 팩** — gq_observe_sys(Qwen
    # 관찰 계약)·gq_compose_sys(Gemini 취합 계약) 둘만 담는다(v3
    # fix_rejudge_header 단일 스템 팩과 같은 패턴). 판정·결함 계약(v7)은
    # 무변경 — v7 디렉토리가 불변이어야 `judge_pack_content` 지문이 OFF
    # 상태에서 안 움직인다.
    "8": "8.202608101400",
    # v9 (2026-08-12 카메라·포즈 다각화 A안): v8 gq 스템 2종 사본 + 관찰
    # 축에 STAGING(카메라 인지·자세 자연스러움 관찰 서술) 추가. GQ(v8
    # 고정)는 미승격 유지 — QK 수정 흐름(QK_CRITIQUE_PACK_VERSION)의
    # 도입판으로만 쓴다(새 체계라 물려받을 구 지문이 없다).
    "9": "9.202608121750",
    # v10 (2026-08-13 육안 8건 wave — 사용자 지시 #106): 3스템 팩.
    # ①gq_observe_sys — v9 사본 + LOOK 축 2종(착석=가구 설계 방향 정합,
    #  가림 연출된 얼굴의 노출·융합) + 심각도 경계 명문(참조 대비 가구·
    #  세트 드레싱 이동/소실은 최대 major — S39sh4 소파가 fix 를 발동시킨
    #  실측이 근거). ②gq_compose_sys — v9 사본 + severity 를 관찰자
    #  rating 그대로 구조 필드로 전달(재평가 금지) + "critical 만 편집"이
    #  코드 게이트로 강제됨을 명문. ③fix_rejudge_header — v7 사본 +
    #  IDENTITY 자동 패배 축(인물이 다른 사람이 된 후보는 무엇을 고쳤든
    #  탈락 — S39sh4 fix 인물 변형 채택이 근거). GQ(v8)·구 critique(v7)
    #  는 무접촉 — 지문 불변.
    "10": "10.202608131121",
    # v11 (2026-08-13 G+G46): 3스템 팩 — gq_observe_sys·fix_rejudge_header
    # 는 v10 사본 그대로, **gq_compose_sys 만** 2관찰자 취합 계약으로 개정
    # (관찰 배열=두 관찰자 목록의 연결, 같은 결함 중복 가능 → 결함당 1건만
    # 채택·중복은 issues 밖으로 — 실측 근거: 관찰 밀도 gemini 42 vs grok
    # 36, 수정-대상 일치 20/23 이라 중복이 상례다). QK(v10)·GQ(v8)·구
    # critique(v7)는 무접촉 — 지문 불변.
    "11": "11.202608132045",
    # v12 (2026-08-14 표기 정책 개정 — 사용자 지시 "원어 텍스트 허용, 꼭
    # 필요한 경우만"): **전체 팩** = v7 전 스템 사본 + v11 gq 3스템 사본
    # 위에 텍스트 축만 개정. ①fix_tail — "No text ... anywhere" 절대문을
    # "장면에 속한 실물 표기는 원본 그대로 보존, 자막·워터마크·오버레이만
    # 금지"로 ②gq_observe_sys — 배제 목록의 no text 를 자막·오버레이
    # 한정으로 좁히고(실물 표기는 깨진 글자·비원어·발명일 때만 결함) 화면
    # 자막 굽기를 critical 열거에 추가 ③critique(legacy)도 동일 방향.
    # judge_still·compose·fix_rejudge_header 등 나머지는 바이트 사본.
    # still_recipe v20(생성측 표기 허용)과 정합 — 생성이 허용한 표기를
    # 판정·수정이 도로 지우는 어긋남을 막는다. GQ(v8)·QK(v10) 무접촉.
    "12": "12.202608141305",
    # v13 (2026-08-19 참조 선별): **추가-스템 전용 팩**(v3·v4·v8 관례) —
    # `fix_missing_head`/`fix_missing_tail` 둘만 담는다. 참조 선별이 켜진
    # 편집에서 "지금 사진에 아예 없는 것을 새로 넣어야 하는" 지적이 있을
    # 때만 수정 지시문에 끼는 절이다(사용자 지시: 그때는 그 참조를 붙이고
    # 지시문에 명시). 판정·결함·수정 본문 계약(v12)은 무접촉 — v12
    # 디렉토리가 불변이어야 `judge_pack_content` 지문이 OFF 상태에서 안
    # 움직인다.
    "13": "13.202608192332",
    # v14 (감사 1-D, 2026-08-27): BUILT SPACE 절에서 **자동차 캐빈 전용
    # 낱말을 걷는다.** "How many steering wheels or control surfaces" 와
    # "(a car cabin, a wheelhouse, a cockpit, a machine)" 이 판정기를
    # 탈것 쪽으로 기울여, 자전거 정비소·엘리베이터 같은 공간에서도 없는
    # 조종 장치를 세게 했다.
    #
    # ★**생성 팩과 같은 커밋에서 올린다.** 한쪽만 고치면 판정이 옛
    #  기준으로 계속 반려해 재시도가 돈다(감사 보고서 지적).
    "14": "14.202608271700",
    # v15 (2026-08-27, 감사 1-D 잔여): v14 가 `judge_still` 만 일반화했다.
    #
    # ★`critique` 에 "a duplicated control surface, a person in the wrong
    #  seat" 이 남아 있었다. **좁은 실내가 아닌 샷까지** 그 낱말을
    #  받는다 — `critique_sys` 는 조건 없이 실린다.
    #  `gq_observe_sys` 도 같다.
    #
    # ★**닿는 범위는 이 selector 를 넘기는 자리뿐이다.**
    #  `still_recipe_service.py` 4곳만 `STILL_JUDGE_PACK_VERSION` 을
    #  넘긴다. `plate_multiroll.py`·`outdoor_structure_seed_step.py` 는
    #  안 넘겨서 **전역 기본 `JUDGE_PACK_VERSION="6"`** 을 쓰고, v6 에는
    #  그 낱말이 그대로 있다. 전역을 안 올린 것은 의도다 — 올리면
    #  인물 없는 산출까지 재실행 범위에 들어온다(2026-08-07 리뷰).
    #  배경 쪽은 v6 기반 별도 팩으로 따로 다룬다.
    "15": "15.202608272340",
    # v16 (2026-08-27, 감사 1-D 잔여 — **전역 갈래**): v15 는 스틸
    #  selector 만 올렸다. `plate_multiroll`·`outdoor_structure_seed` 는
    #  `pack_version` 을 안 넘겨 전역 기본 v6 를 쓰는데, v6 의
    #  `judge_still`·`critique` 에 탈것 낱말이 그대로였다.
    #
    # ★**v15 를 그대로 전역으로 올리지 않는다.** v7~v15 는 인물 물리·
    #  편집 참조처럼 **스틸 전용** 내용이 쌓인 갈래다. 그것이 배경
    #  플레이트·구조물 씨드로 넘어가면 계약과 재실행 범위가 섞인다
    #  (2026-08-07 결정). 그래서 **v6 를 바탕으로 그 두 stem 만**
    #  일반화한 별도 팩을 낸다.
    #
    # ★`fix_ref_label` 을 같이 넣는다 — 내용이 아니라 **계약** 때문이다.
    #  `_load_optional` 이 selector ≥ 7 에서 그 스템을 필수로 보고
    #  fail-closed 로 막는다(v6 에는 없다). 두 소비자 모두 그 값을 읽지
    #  않으므로 동작은 그대로다.
    "16": "16.202608280010",
}
# ★전역 기본은 **v6 계보**를 유지한다 (2026-08-07 Codex 리뷰 수용).
#
# v7 의 내용은 **스틸 전용**이다 — 인물의 물리적 지지, 편집 참조 라벨,
# 수정 범위 축. 그런데 이 전역 하나를 v7 계보로 올리면 구조물 씨드
# (`outdoor_structure_seed_step._config_hash` 의 `judge_pack`)와 배경
# 플레이트(`background_render_step` 의 `plate_multiroll_judge_pack`)까지
# 지문이 바뀌어 통째로 재실행 대상이 된다. 인물이 없는 산출에 인물 물리
# 축을 적용하려고 그 비용을 낼 이유가 없다.
#
# ★그래서 값은 v6 가 아니라 **v6 를 바탕으로 탈것 낱말만 걷은 v16** 이다
#  (2026-08-27, 감사 1-D). 계보는 그대로고 스틸 전용 축은 안 들어온다 —
#  `test_the_global_pack_does_not_carry_the_still_only_axis` 가 잠근다.
JUDGE_PACK_VERSION = "16"
# 스틸(인물 샷) 전용 selector — 스틸 소비자만 명시로 이것을 쓴다.
# v12 (2026-08-14): 표기 정책 개정(fix_tail) 승계 — judge_still 은 v7
# 바이트 사본이라 판정 계약 자체는 불변.
STILL_JUDGE_PACK_VERSION = "15"
# fix-rejudge 중립 헤더 스템 selector (E2E10 fix②) — 소비자·hash 공용 SOT
FIX_REJUDGE_HEADER_PACK_VERSION = "3"
# 스틸 전용 fix-rejudge 헤더 — v7 수정 범위 축(지시하지 않은 변경=손실)
# + v10 IDENTITY 자동 패배 축(2026-08-13 #106: 인물이 다른 사람이 된
# 수정본은 무엇을 고쳤든 탈락 — S39sh4 실측).
STILL_FIX_REJUDGE_HEADER_PACK_VERSION = "10"
# GPT 구도 전담 critique 팩 selector (E2E11 fix③) — 소비자·hash 공용 SOT.
# v4 (2026-07-22): gpt_composition_sys 단일 스템 — 구도·배치·스케일·시선축
# 위반만 보고(정체성/재질/조명 OUT OF SCOPE), Gemini critique 와 합산해
# 하나의 수정 프롬프트로 i2i. 판정 모델=GPT LVM(이미지 검증 분업).
# v5 (E2E13 fix⑤ Codex BLOCKING-2): FIX FEASIBILITY 절 — 카메라 각/축
# 위반의 imperative fix 강제가 unfixable 방어를 우회하던 창 봉합(GPT
# 경로도 시점 이동 fix 금지+unfixable=true 보고).
GPT_COMPOSITION_PACK_VERSION = "5"
GPT_COMPOSITION_MODEL = "gpt"
# G+Q 수정 흐름 스템 팩 selector (2026-08-10) — Qwen 관찰·Gemini 취합 계약.
# 스틸 지문에는 ON 일 때만 접힌다(`gq_critique_pack*` — OFF 무스탬프).
GQ_CRITIQUE_PACK_VERSION = "8"
# QK 수정 흐름 스템 팩 selector (2026-08-12) — Kimi 관찰·Qwen 취합 계약.
# v9(STAGING 관찰 축 추가판)를 도입판으로 썼고, v10(2026-08-13 #106)에서
# 심각도 구조 전달+LOOK 축 2종을 얹었다. GQ(v8 고정)의 지문·산출은
# 1 byte 도 건드리지 않는다.
QK_CRITIQUE_PACK_VERSION = "10"
# G+G46 수정 흐름 스템 팩 selector (2026-08-13) — Gemini+grok-4.6 양쪽
# 관찰·Gemini 취합 계약. v11=v10 사본에 취합 스템만 2관찰자(중복 결함
# 1건 채택) 개정. v12(2026-08-14)=표기 정책 개정 관찰 스템 승계(자막·
# 오버레이만 위반, 실물 원어 표기는 정당). 스틸 지문에는 ON 일 때만
# 접힌다(`gg46_critique_pack*` — OFF 무스탬프).
GG46_CRITIQUE_PACK_VERSION = "12"
# 참조 선별 — "사진에 아예 없는 것을 새로 넣어라" 절 스템 팩 selector
# (2026-08-19). 추가-스템 전용 팩이라 기존 팩 지문에 접히지 않는다.
FIX_MISSING_PACK_VERSION = "13"


def load_fix_missing_texts(
    pack_version: str = FIX_MISSING_PACK_VERSION,
) -> Dict[str, str]:
    """참조 선별 ON 에서만 쓰는 '없는 것 새로 넣기' 절 (2026-08-19).

    수정 지시문은 기본적으로 "여기 적힌 것만 고치고 나머지는 그대로"인데,
    지적이 지금 사진에 **아예 없는** 인물·소품을 넣으라고 할 때가 있다
    (시험 실측 S55sh1 인물 1명·S48sh9 스탠드). 그때만 이 절이 끼고 그
    참조도 함께 붙는다 — 사용자 지시.
    """
    from app.modules.prompt_loader import load_prompt

    resolved = resolve_judge_pack_version(pack_version)
    return {
        "missing_head": load_prompt(
            JUDGE_MODULE, "fix_missing_head", version=resolved).strip(),
        "missing_tail": load_prompt(
            JUDGE_MODULE, "fix_missing_tail", version=resolved).strip(),
    }


def fix_ref_contract_sha() -> str:
    """참조 선별의 실질 지시문 지문 — 팩 밖 **코드 문자열**이라 따로 접는다.

    선별이 실제로 무엇을 붙일지 정하는 문안은 둘이다: 번호가 무엇을
    가리키는지 알려 주는 `_REF_INDEX_HEADER` 와, 결함 검사 스키마 안
    선별 세 칸의 설명("참조를 붙이는 것은 공짜가 아니다 … 애매하면 아무
    것도 적지 마라"). 이 둘은 팩이 아니라 코드에 있어 팩 내용 해시
    (`fix_missing_pack_content`)가 못 덮는다 — 문안을 고치면 붙는 참조와
    최종 그림이 달라지는데 모든 해시가 그대로여서, 반대 정책으로 만든
    산출이 "같은 조건"으로 기록된다(2026-08-08 스테일 재사용 사고와 같은
    부류).

    스키마에서 **되읽어** 해시한다 — 상수를 따로 두고 손으로 맞추면 한쪽만
    고쳐질 때 조용히 어긋난다. 대상은 선별 세 칸으로 한정하므로
    severity·observation_index 등 다른 칸의 문안 개정에는 안 움직인다.
    """
    import hashlib
    import json as _json

    from app.modules.pipeline.multiroll_select import build_critique_schema

    item = build_critique_schema(
        with_ref_gate=True)["properties"]["issues"]["items"]
    gate = {
        k: item["properties"][k]
        for k in ("needs_ref_indices", "adds_missing_entity",
                  "missing_entity_name")
    }
    h = hashlib.sha256(_REF_INDEX_HEADER.encode("utf-8"))
    h.update(b"\x00")
    h.update(_json.dumps(
        gate, sort_keys=True, ensure_ascii=False).encode("utf-8"))
    return h.hexdigest()[:16]


def load_fix_rejudge_header(
    pack_version: str = FIX_REJUDGE_HEADER_PACK_VERSION,
) -> str:
    """fix-rejudge 2후보 중립 판정 헤더 (Codex HIGH-4).

    원본/수정본 provenance 를 노출하지 않고 '타깃 계약 충실도'로만 판정
    — 헤더 문구=팩 v3 fix_rejudge_header 스템 SOT.
    """
    from app.modules.prompt_loader import load_prompt

    return load_prompt(
        JUDGE_MODULE, "fix_rejudge_header",
        version=resolve_judge_pack_version(pack_version),
    ).strip()


def resolve_judge_pack_version(selector: str = JUDGE_PACK_VERSION) -> str:
    try:
        return JUDGE_PACK_VERSION_MAP[selector]
    except KeyError:
        raise ValueError(
            f"unknown multiroll_judge pack selector {selector!r} "
            f"(known: {sorted(JUDGE_PACK_VERSION_MAP)})"
        )


def _pack_dir_content_hash(resolved: str) -> str:
    """판정 팩 내용 해시 — #77 에서 prompt_loader.pack_dir_content_hash 로
    공용화(캐시·fail-closed 포함), 여기는 판정 모듈 위임만 남긴다."""
    from app.modules.prompt_loader import pack_dir_content_hash

    return pack_dir_content_hash(JUDGE_MODULE, resolved)


def judge_pack_content_hash(pack_version: str = JUDGE_PACK_VERSION) -> str:
    """판정 팩의 **내용** 지문 — 버전 문자열이 아니라 로드되는 bytes.

    2026-08-08 확인: 판정 팩 v7 을 만들었는데 최종 스틸 311장 중 253장이
    재판정 없이 재사용됐다. 판정·수정 텍스트는 생성 프롬프트에 들어가지
    않아서, 버전 '문자열' 스탬프는 팩 내용이 바뀌어도 안 움직일 수 있다 —
    이 해시는 **이 프로세스가 실제 로드하는 디렉토리의 bytes** 를 지문에
    접는다. ★낡은 서버(옛 셀렉터를 든 메모리) 탐지책은 아니다(Codex
    HIGH-2): 낡은 프로세스는 옛 팩을 정직하게 해시해 옛 기록과 일치한다 —
    그건 "그 프로세스가 실제로 옛 팩으로 판정한다"는 뜻이라 지문으로는
    옳고, 스테일 서버 여부는 재기동 시각 확인으로 따로 잡아야 한다.
    """
    return _pack_dir_content_hash(resolve_judge_pack_version(pack_version))


def atomic_write_bytes(out_path: Path, data: bytes) -> Path:
    """tmp 생성→replace — write 도중 crash 로 truncated 파일이 '존재'로
    오인돼 skip 되는 창 제거 (Codex 2차 리뷰 B2)."""
    out_path.parent.mkdir(parents=True, exist_ok=True)
    tmp = out_path.with_name(out_path.name + ".tmp")
    tmp.write_bytes(data)
    tmp.replace(out_path)
    return out_path


# 매직 바이트 → media type. 확장자·파일명이 아니라 **내용**으로 판정한다.
# 2026-08-06 실측: 참조 중 일부가 `.png` 이름을 달고 실제로는 JPEG 였다.
# Gemini 는 media type 불일치를 조용히 넘겼지만 Anthropic 은 400 으로 거부한다
# ("specified using the image/png media type, but the image appears to be
#  a image/jpeg image"). 선정 판정을 Claude 로 옮기는 순간 전 호출이 죽는다.
_IMAGE_MAGIC: Tuple[Tuple[bytes, str], ...] = (
    (b"\x89PNG\r\n\x1a\n", "image/png"),
    (b"\xff\xd8\xff", "image/jpeg"),
    (b"GIF87a", "image/gif"),
    (b"GIF89a", "image/gif"),
)


def _sniff_media_type(data: bytes) -> str:
    """바이트 내용으로 media type 판정 — 못 알아보면 png 로 둔다(기존 동작)."""
    for magic, mime in _IMAGE_MAGIC:
        if data.startswith(magic):
            return mime
    if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
        return "image/webp"
    return "image/png"


def png_part(source: Any) -> Dict[str, Any]:
    """이미지 소스(Path|bytes) → data URL 멀티모달 part.

    media type 은 **내용**으로 정한다 (`_sniff_media_type`). 이름이 `.png` 라도
    실제가 JPEG 이면 `image/jpeg` 로 나간다.
    """
    data = (
        Path(source).read_bytes()
        if isinstance(source, (str, Path)) else source
    )
    b64 = base64.b64encode(data).decode("ascii")
    return {
        "type": "image_url",
        "image_url": {
            "url": f"data:{_sniff_media_type(data)};base64,{b64}",
        },
    }


def resolve_judge_texts(
    roll_count: int,
    judge_name: str = "judge_still",
    pack_version: str = JUDGE_PACK_VERSION,
) -> Dict[str, str]:
    """multiroll_judge 팩 로드 — 후보 수 단어({count_word})만 포맷.

    v2 부터 명시 selector 로만 해석(암묵 latest-pack 승격 금지 — 팩
    디렉토리 추가만으로 전 소비자 판정 계약이 바뀌던 창 제거).
    """
    from app.modules.pipeline.multiroll_select import roll_labels
    from app.modules.prompt_loader import load_prompt

    resolved = resolve_judge_pack_version(pack_version)
    word = _COUNT_WORDS.get(roll_count, str(roll_count))
    labels = ", ".join(roll_labels(roll_count))
    return {
        "judge_sys": load_prompt(
            JUDGE_MODULE, judge_name, count_word=word, label_list=labels,
            version=resolved,
        ),
        "critique_sys": load_prompt(
            JUDGE_MODULE, "critique", count_word_lower=word.lower(),
            version=resolved,
        ),
        "fix_head": load_prompt(
            JUDGE_MODULE, "fix_head", version=resolved).strip(),
        "fix_tail": load_prompt(
            JUDGE_MODULE, "fix_tail", version=resolved).strip(),
        "fix_label": load_prompt(
            JUDGE_MODULE, "fix_label", version=resolved).strip(),
        # 팩 v7 신규 — 편집 호출에 동봉하는 참조의 라벨. v7 미만 팩에는
        # 스템이 없으므로 빈 문자열(기존 동작), v7 이상에서는 fail-closed.
        "fix_ref_label": _load_optional(
            resolved, "fix_ref_label", pack_version),
    }


# 이 스템들이 처음 들어온 팩 — 그보다 앞선 팩에는 없는 것이 정상이다.
_STEM_INTRODUCED_IN = {"fix_ref_label": "7"}


# ── 스틸 전용 재생성 문안 (2026-08-29 사용자 지시) ────────────────────
#
# ★judge 팩에 넣지 않고 **별도 모듈**로 뺐다. judge 팩을 올리면 판정·비평
#  문안까지 함께 바뀌어 **이미 완료된 샷이 통째로 stale** 이 된다 —
#  수리 수단 하나 더 만들자고 전 주행을 다시 굽게 할 수는 없다.
#  별도 팩이면 「repair master ON 일 때만 지문에 접는다」도 자연히 성립한다.
#
# ★구조물(`multiroll_select.REGEN_HEAD/TAIL`) 문안을 재사용하지 않는다.
#  그쪽은 「location photograph」「storey count · massing · footprint」
#  전용이라 스틸에 쓰면 지적의 대상이 뒤바뀐다.
STILL_REGEN_MODULE = "still_repair_regen"
#: selector → 디렉토리. 이 프로젝트 관례대로 **명시 map** 이다 — 디렉토리를
#  하나 더 두는 것만으로 소비자 계약이 조용히 바뀌지 않게.
STILL_REGEN_VERSION_MAP = {"1": "1.202608290400"}
STILL_REGEN_PACK_VERSION = "1"
#: 이 팩의 스템 — 하나라도 빠지면 재생성이 문안 없이 나간다.
STILL_REGEN_STEMS = ("regen_head", "regen_tail", "regen_faults_header")


def resolve_still_regen_texts(
    pack_version: str = STILL_REGEN_PACK_VERSION,
) -> Dict[str, str]:
    """스틸 재생성 문안 셋 — head / tail / 결함 목록 머리말.

    ★fail-closed: 알 수 없는 selector 도, 없는 스템도 올린다. 빈 문자열로
     떨어지면 재생성이 **머리말도 꼬리말도 없이** 나가고 기록에는 이 팩으로
     남는다 — `fix_ref_label` 에서 이미 겪은 형태다.
    """
    from app.modules.prompt_loader import load_prompt

    try:
        resolved = STILL_REGEN_VERSION_MAP[str(pack_version)]
    except KeyError:
        raise ValueError(
            f"unknown {STILL_REGEN_MODULE} pack selector {pack_version!r} "
            f"(known: {sorted(STILL_REGEN_VERSION_MAP)})")
    return {
        "regen_head": load_prompt(
            STILL_REGEN_MODULE, "regen_head", version=resolved).strip(),
        "regen_tail": load_prompt(
            STILL_REGEN_MODULE, "regen_tail", version=resolved).strip(),
        "regen_faults_header": load_prompt(
            STILL_REGEN_MODULE, "regen_faults_header",
            version=resolved).strip(),
    }


def _selector_num(selector: str) -> int:
    """팩 selector → 정수. 해석 불가는 -1(=도입 팩 미만으로 취급).

    -1 로 떨어뜨리는 쪽이 안전하다: 알 수 없는 selector 에 fail-closed 를
    걸면 그 팩의 정상 소비까지 막힌다. 반대로 필수 스템 결손을 놓치는 위험은
    `resolve_judge_pack_version` 이 selector 자체를 먼저 검증해 좁혀 준다.
    """
    try:
        return int(str(selector).strip())
    except (TypeError, ValueError):
        return -1


def _load_optional(resolved: str, name: str, selector: str) -> str:
    """구 팩에는 없을 수 있는 스템. **도입 팩 이상에서는 fail-closed.**

    소비자는 빈 문자열을 "그 기능 없음"으로 읽는다. 구 selector 로 내렸을 때
    부팅이 깨지지 않게 하려는 것이지, 스템 결손·손상을 삼키려는 것이 아니다.

    삼키면 어떻게 되는지가 이번 결함의 형태다(Codex 리뷰 수용): 팩이 v7 인데
    `fix_ref_label` 이 없거나 읽히지 않으면 편집이 조용히 참조 0장으로
    떨어지고, **바로 그것을 고치려던 유료 수정이 옛 동작으로 실행된다.**
    기록에는 v7 이라 남는다. 그래서 도입 팩 이상에서는 막는다.
    """
    from app.modules.prompt_loader import load_prompt

    # ★버전은 **숫자**로 비교한다 (2026-08-07 Codex 3차 리뷰). 문자열로
    #  비교하면 `"10" >= "7"` 이 False 라, v10 이상에서 필수 스템이 결손돼도
    #  fail-closed 가 풀려 유료 편집이 참조 없이 실행된다 — 기록에는 새 팩으로
    #  남는다. 이 프로젝트의 selector 는 십진 정수 문자열이다.
    required_from = _STEM_INTRODUCED_IN.get(name)
    required = required_from is not None and _selector_num(
        selector) >= _selector_num(required_from)
    try:
        text = load_prompt(JUDGE_MODULE, name, version=resolved)
    except (FileNotFoundError, KeyError) as exc:
        if required:
            raise ValueError(
                f"multiroll_judge 팩 {resolved} 에 필수 스템 {name!r} 이 없다 "
                f"— 조용한 강등 금지(fail-closed)") from exc
        return ""
    text = (text or "").strip()
    if required and not text:
        raise ValueError(
            f"multiroll_judge 팩 {resolved} 의 {name!r} 이 비어 있다 "
            f"— 조용한 강등 금지(fail-closed)")
    return text


def ref_parts(labeled_refs: Sequence[Tuple[str, Any]]) -> List[Dict[str, Any]]:
    parts: List[Dict[str, Any]] = []
    for label, src in labeled_refs:
        parts.append({"type": "text", "text": f"REFERENCE — {label}"})
        parts.append(png_part(src))
    return parts


# 참조 선별 (2026-08-19) — 취합자가 지적마다 "어느 참조를 봐야 하는가"를
# 번호로 답하려면 번호가 무엇을 가리키는지 알아야 한다. 위 ref_parts 는
# 라벨만 붙이고 번호를 안 붙이므로(모든 소비자 공용이라 손대지 않는다)
# 취합 호출에만 이 목록을 덧붙인다. 순서는 labeled_refs 순서 그대로이고,
# 그 순서가 `needs_ref_indices` 의 계약이다.
_REF_INDEX_HEADER = (
    "REFERENCE INDEX — the reference photographs above, numbered in the "
    "order they were given. Use these numbers, and only these, in "
    "`needs_ref_indices`:"
)


def ref_index_part(
    labeled_refs: Sequence[Tuple[str, Any]]
) -> Dict[str, Any]:
    lines = [f"{i}. {label}"
             for i, (label, _src) in enumerate(labeled_refs, 1)]
    body = "\n".join(lines) if lines else "(none were attached)"
    return {"type": "text", "text": f"{_REF_INDEX_HEADER}\n{body}"}


_OBS_HEADER = (
    "OBSERVATIONS (from a separate visual inspector — verify "
    "each against the photograph before adopting it):")


def observations_part(
    observations: Sequence[Dict[str, Any]],
    critique_schema: Dict[str, Any],
    header: str = _OBS_HEADER,
) -> Dict[str, Any]:
    """관찰을 취합에 보여 주는 파트 — **취합이 받을 수 있는 칸만** 남긴다.

    ## 왜 거르나 — 실측 254 중 76건(30%)이 여기서 죽었다

    2026-08-27 DB 실측(14일):

        still_recipe_critique_compose   성공 178 · **실패 76**
        사유 1위 `schema violation: Additional properties are not allowed
                 ('severity' was unexpected)`

    관찰 스키마는 `{issue_ko, severity}` 를 내고 **`severity` 가
    required** 다. 그 JSON 을 통째로 취합 입력에 넣는데, GQ 취합
    스키마는 `{fix_en, issue_ko, needs_regeneration, unfixable}` 에
    `additionalProperties: False` 다.

    ★**모델은 입력에 있는 칸을 출력에도 넣는다.** 그게 자연스러운
     행동이고, 그때마다 스키마가 거부해 유료 왕복이 버려진다
     (`max_retry=1` 이라 재질의도 한 번 더 산다).

    ## 왜 스키마를 넓히지 않았나

    취합 스키마에 `severity` 를 받게 하면 GQ 경로 이슈에 severity 가
    실리기 시작하고, `multiroll_select` 의 심각도 게이트
    (`if any("severity" in issue …)`)가 **GQ 에서도 걸리기 시작한다.**
    지금 GQ 는 major 도 편집 대상인데 갑자기 critical 만 남는다 —
    ★거동이 통째로 바뀐다. 실측으로 확인한 것이다(2026-08-27:
     composition 이 돈 200샷이 전부 `main sev없음`).

    그래서 **입력을 계약에 맞춘다.** 안 받을 칸을 안 보여 주면 따라
    낼 일이 없고, 거동은 그대로다.

    ★허용 칸은 **스키마에서 읽는다.** 목록을 손으로 적으면 스키마가
     바뀔 때 조용히 어긋난다.
    """
    props = (((critique_schema or {}).get("properties") or {})
             .get("issues") or {}).get("items") or {}
    allowed = set((props.get("properties") or {}))
    kept: List[Dict[str, Any]] = []
    for obs in observations or []:
        if not isinstance(obs, dict):
            continue
        # 허용 목록을 못 읽었으면 **거르지 않는다** — 빈 집합으로 걸러
        # 관찰을 통째로 비우면 취합이 볼 것이 없어진다.
        kept.append({k: v for k, v in obs.items() if k in allowed}
                    if allowed else dict(obs))
    return {
        "type": "text",
        "text": header + "\n" + json.dumps(
            kept, ensure_ascii=False, indent=1),
    }


def _is_transport_error(exc: BaseException) -> bool:
    """운반층 오류인가 — 시간 초과·연결 끊김·HTTP 오류.

    ★**타입으로** 가른다. 오류 문구를 글자로 읽지 않는다(이 저장소 규칙).
     `urllib.error.HTTPError` 는 `URLError` 의 하위라 한 줄로 잡힌다.
    """
    import socket
    import urllib.error

    return isinstance(exc, (urllib.error.URLError, TimeoutError,
                            socket.timeout, ConnectionError))


def make_nb2_gen_fn(
    *,
    project_id: str,
    episode_id: Optional[str] = None,
    operation_type: str = "multiroll_roll",
    aspect_ratio: str = "16:9",
    sanitizer: Any = None,
    gemini_client: Any = None,
    context_extra: Optional[Dict[str, Any]] = None,
) -> Callable:
    """nb2 라벨드 참조 생성 gen_fn — moderation 시 sanitizer 1회 재시도.

    context_extra: set_context 에 병합할 추가 trace 필드(still_id 등) —
    llm_call_log 구조키 매칭(generation_call_id resolve)의 전제.

    still_safety_fallback_enabled=True 면 moderation 거부에 다단 후퇴
    사다리(2026-08-16 사용자 지시): 원 모델 재시도 → 교차 백엔드
    (Gemini↔Grok) → 연화 → 교차+연화. OFF(default)=기존 2회(원문→연화)
    byte-identical. 사다리가 삼키는 오류는 moderation 과
    GrokPromptOverBudget(무호출 비호환)뿐 — 그 밖의 오류·예산 소진은
    어느 단계든 즉시 전파(Codex BLOCK-3).
    """
    if gemini_client is None:
        # 최종 스틸 생성 엔진 스위치 (2026-08-13 사용자 확정 "grok 2 로
        # 가자"): 기본 nb2(Gemini) — byte-identical. "grok2" 면 OpenRouter
        # 경유 xAI Grok Imagine 2.0 (동일 gen_fn 계약: set_context/
        # generate_image/labeled_references/기록·캡처).
        from app.core.config import settings as _sw_settings

        _backend = getattr(_sw_settings, "still_image_backend", "nb2")
        if _backend == "grok2":
            from app.modules.llm.grok_image_client import GrokImageClient

            gemini_client = GrokImageClient()
        elif _backend == "gpt25":
            # ★2026-09-20 사용자 결정 — 조립을 gpt-image-2.5 로.
            #  사다리의 `_cross_client()` 가 「primary 가 Grok 이 아니면
            #  Grok」이라, 이걸 primary 로 두면 **교차 grok → seedream**
            #  순서가 지시한 그대로 나온다.
            from app.modules.llm.gpt_image_client import GptImageClient

            gemini_client = GptImageClient()
        else:
            from app.modules.llm.gemini_image_client import GeminiImageClient

            gemini_client = GeminiImageClient()

    def _moderated(exc: Exception) -> bool:
        msg = str(exc).lower()
        return (
            "moderation" in msg or "safety" in msg
            or "blocked" in msg or "content_policy" in msg
        )

    def gen_fn(tag, prompt, labeled_refs, out_path: Path) -> Path:
        current = prompt
        for attempt in (1, 2):
            try:
                gemini_client.set_context(
                    project_id=project_id, episode_id=episode_id,
                    operation_type=operation_type,
                    # Codex 8e70d4c0 H2: 호출별 롤/fix 태그를 구조키로 기록
                    # (예: still_S4sh1_ab_conti_a / ..._fix) — 최종 asset
                    # 감사 링크가 selected roll/fix 를 exact 로 가리키는
                    # 전제. context_extra 가 같은 키를 주면 그쪽 우선.
                    **{"multiroll_tag": tag, **(context_extra or {})},
                )
                png, _ms = gemini_client.generate_image(
                    current,
                    labeled_references=[
                        (label,
                         Path(p).read_bytes()
                         if isinstance(p, (str, Path)) else p)
                        for label, p in labeled_refs
                    ],
                    aspect_ratio=aspect_ratio,
                )
                return atomic_write_bytes(out_path, png)
            except Exception as exc:  # noqa: BLE001
                if attempt == 1 and _moderated(exc) and sanitizer is not None:
                    sr = sanitizer.sanitize(current, str(exc), [], attempt)
                    sanitized = sr.get("sanitized_prompt") or ""
                    if sanitized:
                        logger.warning(
                            "multiroll_gemini %s: moderation — sanitize "
                            "재시도", tag,
                        )
                        current = sanitized
                        continue
                raise
        raise RuntimeError("unreachable")

    def gen_fn_ladder(tag, prompt, labeled_refs, out_path: Path) -> Path:
        """SAFETY 다단 후퇴 — 단계: primary → 원 모델 재시도 → 교차 백엔드
        → **세 번째 백엔드(seedream)** → 연화(원 모델) → 연화(교차).
        연화 저작은 1회만, 교차·세 번째 클라이언트도 필요할 때 1회만 만든다.

        ★순서의 뜻 (2026-09-19 사용자 지시 「nb2 조합도 문제되면 seedream」):
         **제공자를 다 써 본 다음에야 글을 고친다.** 연화는 요청한 것을
         지운다 — 실측으로 앰버(10세)의 다친 모습이 세 판 모두 말짱한
         그림이 됐다. seedream 은 운반층이 아예 달라 막는 선도 다르고,
         그 한 장을 실제로 받아 냈다.

        비용 경계(Codex BLOCK-3): 사다리 **단계** 상한 6. 단계당 provider
        재시도는 클라이언트 규칙 그대로다 — moderation 은 내부 재시도 없이
        즉시 올라오고(정상 응답의 차단 사유), transient HTTP 만 최대
        llm_max_retries. 사다리가 삼키는 오류는 moderation 과
        GrokPromptOverBudget(무호출·결정론적 비호환) 둘뿐 — 그 밖의 오류와
        ImageCallBudgetExceeded(batch abort 계약)는 어느 단계든 즉시 전파.
        """
        from app.core.image_call_budget import ImageCallBudgetExceeded
        from app.modules.llm.grok_image_client import GrokPromptOverBudget
        refs = [
            (label,
             Path(p).read_bytes() if isinstance(p, (str, Path)) else p)
            for label, p in labeled_refs
        ]

        def _cross_client() -> Any:
            # 교차 백엔드 = 기본 클라이언트와 다른 검열 스택. ★상속 관계
            # (Grok ⊂ Gemini)라 Grok 판별을 먼저 한다.
            from app.modules.llm.gemini_image_client import GeminiImageClient
            from app.modules.llm.grok_image_client import GrokImageClient

            if isinstance(gemini_client, GrokImageClient):
                return GeminiImageClient()
            return GrokImageClient()

        def _third_client() -> Any:
            """세 번째 검열 스택 — Seedream (2026-09-19 사용자 지시).

            ★「nb2 조합도 문제되면 seedream 으로」. 종전 사다리는
             Gemini↔Grok 둘뿐이라, 둘 다 막히면 **글을 부드럽게** 하는
             수밖에 없었다. 글을 고치면 요청한 것이 사라진다 — 실측:
             앰버(10세)의 다친 모습이 세 판 모두 말짱한 그림이 됐다.
             seedream 은 운반층이 아예 달라서(OpenRouter 전용 이미지
             엔드포인트) 막는 선도 다르다. 실측으로 그 한 장을 받아 냈다.
            """
            from app.modules.pipeline.cine_provider import build_cine_client

            return build_cine_client("seedream")

        # (stage, 어느 client, 연화 여부) — stage 문자열은 llm_call_log
        # metadata(safety_ladder)로 남아 발화 흔적의 SOT 가 된다.
        #
        # ★순서의 뜻: **제공자를 다 써 본 다음에야 글을 고친다.**
        #  연화는 요청한 것을 지우므로 마지막 수단이다.
        stages = (
            ("primary", None, False),
            ("same_model_retry", None, False),
            ("cross_backend", "cross", False),
            ("third_backend", "third", False),
            ("sanitized", None, True),
            ("cross_sanitized", "cross", True),
        )
        # ★상한은 숫자가 아니라 **문의 자리**로 정해진다. 위 주석에만
        #  적어 두면 단계를 늘릴 때 둘이 어긋난다 — 실제로 5 라고 적힌 채
        #  6 으로 늘어났다. 그래서 여기서 센다.
        _LADDER_STAGE_CAP = 6
        if len(stages) > _LADDER_STAGE_CAP:
            raise RuntimeError(
                f"safety ladder 단계가 승인 상한을 넘었다: "
                f"{len(stages)} > {_LADDER_STAGE_CAP}. 단계를 늘리려면 "
                f"상한과 docstring 을 **같이** 고치고 비용 경계를 다시 본다.")
        cross: Any = None
        third: Any = None
        sanitized: Optional[str] = None
        last_exc: Optional[Exception] = None
        moderation_exc: Optional[Exception] = None
        for stage, use_cross, use_sanitized in stages:
            if use_sanitized and sanitized is None:
                if sanitizer is None:
                    break
                sr = sanitizer.sanitize(
                    prompt, str(moderation_exc or last_exc), [], 1)
                sanitized = (sr.get("sanitized_prompt") or "").strip()
                if not sanitized:
                    break  # 연화 실패 — 남은 단계가 전부 연화 의존
            client = gemini_client
            if use_cross == "cross":
                if cross is None:
                    cross = _cross_client()
                client = cross
            elif use_cross == "third":
                if third is None:
                    third = _third_client()
                client = third
            try:
                extra = {"multiroll_tag": tag, **(context_extra or {})}
                # (Codex BLOCK-1) set_context 는 update 라 이전 샷의
                # 단계명이 공유 클라이언트에 잔류한다 — primary 도 항상
                # 키를 실어 None 으로 명시 청소(projection 이 None 을
                # 걸러 메타에는 안 남는다).
                extra["safety_ladder"] = (
                    stage if stage != "primary" else None)
                client.set_context(
                    project_id=project_id, episode_id=episode_id,
                    operation_type=operation_type, **extra)
                png, _ms = client.generate_image(
                    sanitized if use_sanitized else prompt,
                    labeled_references=refs, aspect_ratio=aspect_ratio)
                if stage != "primary":
                    logger.warning(
                        "multiroll_gemini %s: safety ladder %s 단계에서 "
                        "회복", tag, stage)
                return atomic_write_bytes(out_path, png)
            except ImageCallBudgetExceeded:
                # (Codex BLOCK-3a) 예산 소진은 batch abort 계약 — 어느
                # 단계든 즉시 전파, 유료 연화·후속 단계 진행 금지.
                raise
            except GrokPromptOverBudget as exc:
                # 무호출·결정론적 백엔드 비호환(8k 상한). (Codex R2
                # BLOCK) moderation 으로 후퇴 국면에 들어와 있을 때만
                # 이 단계를 건너뛴다 — moderation 이 한 번도 없었는데
                # 삼키면 SAFETY 무관 입력(primary Grok+긴 프롬프트)에서
                # 유료 교차가 발화해 OFF(즉시 실패)와 어긋나고, 기록의
                # safety_ladder 도 SAFETY 회복이 아닌데 그렇게 읽힌다.
                if moderation_exc is None:
                    raise
                last_exc = exc
                logger.warning(
                    "multiroll_gemini %s: safety ladder %s 단계 백엔드 "
                    "비호환(%s) — 다음 단계", tag, stage, str(exc)[:200])
            except Exception as exc:  # noqa: BLE001
                if not _moderated(exc):
                    # ★★세 번째 백엔드의 **운반 오류**만 좁혀서 넘긴다
                    #  (2026-09-19 실측). seedream 이 2분 반을 못 버티고
                    #  HTTP 524(시간 초과)를 냈는데, 이 갈래가 그대로
                    #  던져서 **롤 하나가 죽고 샷 전체가 실패**했다 — 다른
                    #  롤은 grok 으로 멀쩡히 성공해 있었는데도.
                    #
                    #  넘기는 조건은 셋이 **모두** 맞을 때뿐이다.
                    #   ① 이미 검열로 후퇴 중이다(moderation_exc 있음)
                    #   ② 지금 단계가 세 번째 백엔드다(대체 제공자)
                    #   ③ 오류가 운반층 오류다(시간 초과·연결 끊김·HTTP)
                    #  그 밖은 기존 계약(Codex BLOCK-3b) 그대로 즉시 전파.
                    #  다음 단계는 원래 계획된 연화이고 상한 6 안이다.
                    if (moderation_exc is not None
                            and use_cross == "third"
                            and _is_transport_error(exc)):
                        last_exc = exc
                        logger.warning(
                            "multiroll_gemini %s: safety ladder %s 단계 "
                            "대체 제공자 운반 오류(%s) — 다음 단계",
                            tag, stage, str(exc)[:160])
                        continue
                    # (Codex BLOCK-3b) moderation 밖 오류는 어느 단계든
                    # 즉시 전파 — 삼키면 실패 원인이 오독되고 provider
                    # 내부 재시도(transient 최대 4 HTTP)와 곱해져 비용
                    # 창이 열린다.
                    raise
                last_exc = exc
                moderation_exc = exc
                logger.warning(
                    "multiroll_gemini %s: safety ladder %s 단계 "
                    "moderation(%s) — 다음 단계", tag, stage,
                    str(exc)[:200])
        # 전 단계 소진 — 걷기 단위 실패 격리로 전파. 삼킨 오류는
        # moderation/백엔드 비호환뿐이므로 원 사유(moderation)를 올린다.
        raise (moderation_exc or last_exc
               or RuntimeError("safety ladder: no attempt ran"))

    from app.core.config import settings as _lad_settings

    if bool(getattr(_lad_settings, "still_safety_fallback_enabled", False)):
        return gen_fn_ladder
    return gen_fn


def make_gemini_judge_fn(
    *,
    judge_sys: str,
    judge_schema: Dict[str, Any],
    project_config: Optional[Dict[str, Any]] = None,
    step_tag: str = "multiroll_judge",
    opik_metadata: Optional[Dict[str, Any]] = None,
    prompt_header: str = (
        "THE PROMPT (all candidates were generated from this):"
    ),
) -> Callable:
    """prompt_header (seed 품질 2R): 변형 롤 모드는 후보들이 서로 다른 저작
    프롬프트로 생성되므로 기존 헤더 문구가 거짓 — 브리프용 헤더로 교체 가능.
    미지정=기존 문구 byte-identical.

    ★모델: 후보 선정 판정이므로 `resolve_select_judge_models()` 를 쓴다 —
    G+Q ON 이면 **Gemini + Qwen 동시**(합의 규칙 `_judge_gq`), OFF 는
    ANTHROPIC_API_KEY 가 있으면 Claude Opus + GPT-5.6 Sol 조건부 이중
    (`_judge_dual`/`combine_select_verdicts`), 없으면 `gemini-pro` 단독."""
    from app.modules.llm.llm_client import call_structured

    models = resolve_select_judge_models()
    base_pc = dict(project_config or {})
    # QK 반전 구성(메인=Qwen 센티널이 앞) 여부 — Gemini 보조 호출 봉인에
    # 쓴다 (Codex 반전 리뷰 BLOCK-2). 기존 GQ(Gemini 앞)는 기본 fallback
    # 유지 = byte-identical.
    # ★갈래는 `select_judge_pair_kind` 하나가 정한다 (2026-08-29). 아래
    #  세 자리가 각자 목록 모양을 뜯어보다 둘째 심판이 GPT 로 바뀌면 조용히
    #  어긋나던 것을 막는다 — 같은 함수·같은 입력이라 갈릴 수가 없다.
    _pair = select_judge_pair_kind(models)
    _qk_reversed = _pair == PAIR_QK_REVERSED
    # cross-model(정순/역순) 갈래로 **실제로 가는가** — dispatch 와 같은 값.
    # 봉인 계약은 `_one` 안에서 같은 `_pair` 로 정한다(Codex BLOCK-4).
    _cross_model_order = _pair == PAIR_EQUAL_CROSS

    def _one(model: str, parts, tag_suffix: str) -> Dict[str, Any]:
        tag = f"{step_tag}{tag_suffix}"
        if model == QWEN_JUDGE_MODEL:
            # Qwen 은 Router 미등록(json_schema 미지원) — 우회 클라이언트로.
            # 판정 sys·schema 는 Gemini 와 **공용**(같은 계약, 같은 후보 제시).
            from app.modules.llm.qwen_vlm_client import ask_qwen_structured

            return ask_qwen_structured(
                tag, judge_sys, parts, judge_schema,
                opik_metadata=opik_metadata,
            )
        if model.startswith(OPENROUTER_JUDGE_PREFIX):
            # QK 판정(2026-08-12) — OpenRouter 경유. 판정 sys·schema 는
            # 기존 심판과 **공용**(같은 계약, 같은 후보 제시).
            from app.modules.llm.openrouter_vlm_client import (
                ask_openrouter_structured,
            )

            return ask_openrouter_structured(
                tag, judge_sys, parts, judge_schema,
                model=model[len(OPENROUTER_JUDGE_PREFIX):],
                # 파일럿 검증 구성(2026-08-13): 판정류 긴 구조화 출력은
                # max_tokens 미달 잘림이 실측 함정(qwen 4000 잘림 14건) —
                # grok-4.6 23샷 파일럿도 8000 으로 통과했다. 상한이 아니라
                # 출력 예산이다(reasoning 은 별도).
                max_tokens=8000,
                opik_metadata=opik_metadata,
            )
        # QK 반전의 Gemini 보조는 **Gemini 로 봉인**(enable_fallback=False —
        # Codex BLOCK-2): safety 강등 시 GPT Tier3 가 판정했는데 기록
        # (per_model_winner['gemini-pro'])과 지문(물리 쌍)은 Gemini 로 남는
        # 오귀속 차단. 실패는 _judge_gq 가 정직한 single_qwen-vlm route 로
        # 처리한다. 기존 GQ 경로는 기본값 유지(byte-identical).
        # ★동등 교차쌍은 **두 슬롯 다** 봉인한다 (2026-08-29 Codex BLOCK-4).
        #
        #  종전에는 Gemini 슬롯만 `enable_fallback=False` 였다. 둘째가
        #  OpenRouter 일 때는 그래도 됐다 — 그 갈래는 Router 를 안 타서
        #  한 번만 나갔다. 그런데 둘째가 GPT alias 로 바뀌면 **Router 를
        #  타므로** Tier2/3 강등까지 열린다. 그러면 「Gemini 정순 1 + 둘째
        #  역순 1, 정확히 두 번」이 거짓이 된다.
        #
        #  그리고 `enable_fallback=False` 만으로는 모자란다 — 그 인자는
        #  이 파일의 Tier 2/3 만 닫고 **Router 자체는
        #  `num_retries=settings.llm_max_retries`(기본 3)로 지어진다**
        #  (`llm_client.py:1200-1206` 주석). 한 alias 가 최대 4번 나갈 수
        #  있고, `usage_sink` 는 마지막 응답만 보므로 앞선 과금이 기록에서
        #  사라진다. 「모델마다 한 번씩」이 계약인 자리라 0 을 내려보낸다.
        #
        #  ★잃는 것: 일시 오류에 재시도가 없다. 한 슬롯이 죽으면
        #  `single_forward`/`single_reverse` 로 정직하게 남는다 — 조용히
        #  두 배 사고 마지막 것만 기록하는 것보다 낫다.
        _seal: Dict[str, Any] = {}
        if _pair == PAIR_EQUAL_CROSS:
            _seal = {"enable_fallback": False, "num_retries": 0}
        elif _qk_reversed and model == JUDGE_MODEL:
            # QK 반전의 Gemini 보조 봉인은 **종전 그대로** — 이번 지적의
            # 범위 밖이다. 재시도까지 건드리면 원래 막던 실패가 되살아난다.
            _seal = {"enable_fallback": False}
        return call_structured(
            tag, judge_sys, parts, judge_schema,
            project_config={**base_pc, tag: {"model": model}},
            schema_name=step_tag, opik_metadata=opik_metadata, **_seal,
        )

    # ★보내기 **전에** 후보·라벨·스키마가 한 쌍인지 본다 (2026-09-19 S11sh1).
    #  어긋난 채 보내면 두 심판 값을 다 치르고 「전건 실패」로 돌아온다 —
    #  그 오류문으로는 판정기가 틀린 건지 모델이 틀린 건지 못 가른다.
    _schema_labels = list(
        ((judge_schema.get("properties") or {}).get("winner") or {})
        .get("enum") or [])

    def judge_fn(tag, prompt, labeled_refs, cand_paths, labels):
        _labs = list(labels)
        if len(_labs) != len(cand_paths) or (
                _schema_labels and (len(_labs) != len(_schema_labels)
                                    or set(_labs) != set(_schema_labels))):
            raise ValueError(
                f"judge 계약 불일치[{tag}] — 후보 {len(cand_paths)}장 · "
                f"라벨 {_labs} · 스키마 라벨 {_schema_labels} "
                "(보내기 전에 막는다)")
        parts: List[Dict[str, Any]] = [{
            "type": "text",
            "text": prompt_header + "\n" + prompt,
        }]
        parts += ref_parts(labeled_refs)
        for lab, p in zip(labels, cand_paths):
            parts.append({"type": "text", "text": f"Candidate {lab}:"})
            parts.append(png_part(p))
        if _pair != PAIR_SINGLE:
            # ★갈래는 위에서 `select_judge_pair_kind` 가 이미 정했다 —
            #  여기서 목록 모양을 **다시 뜯어보지 않는다.** 그래야
            #  `owns_order`·fallback 봉인과 조용히 갈릴 수가 없다.
            if _pair == PAIR_QK_REVERSED:
                # QK 반전(Qwen 메인) — 2026-08-12
                return _judge_gq(models, _one, parts, list(labels),
                                 priority_route="qwen_priority")
            if _pair == PAIR_GQ:
                return _judge_gq(models, _one, parts, list(labels))
            if _pair == PAIR_EQUAL_CROSS:
                # ★2026-08-29 사용자 지시 — **Gemini 정순 · 둘째 역순, 2콜**.
                #  종전 「2모델 × 2순서 = 4콜 순차」를 2콜 병렬로 줄인다.
                #  어댑터가 두 호출·역매핑·결합을 **혼자 소유**한다 — 바깥이
                #  이 함수를 정·역으로 두 번 태우면 다시 4콜이 된다.
                #  둘째 슬롯은 2026-08-29 부터 GPT-5.6 Sol 이다(그 전엔
                #  grok-4.6). 우선권 없음, 불일치=combined 합산.
                return _judge_cross_model_order(
                    models, _one, prompt_header + "\n" + prompt,
                    labeled_refs, cand_paths, list(labels))
            if _pair == PAIR_OPENROUTER_PRIORITY:
                return _judge_gq(models, _one, parts, list(labels),
                                 priority_route="qwen_priority")
            return _judge_dual(models, _one, parts, list(labels))
        return call_structured(
            step_tag, judge_sys, parts, judge_schema,
            project_config={**base_pc,
                            step_tag: {"model": models[0]}},
            schema_name=step_tag,
            opik_metadata=opik_metadata,
        )

    # ★이 판정자가 **순서를 스스로 소유**하는지 바깥에 알린다 (2026-08-29).
    #
    #  cross-model 갈래는 Gemini 정순 + Grok 역순을 어댑터 **한 번** 안에서
    #  다 한다. 그런데 `run_multiroll_select` 의 옛 `judge_flip` 은 이
    #  함수를 **정순·역순 두 번** 태운다 — 그러면 2×2 = **다시 4콜**이 되어
    #  사용자 지시("즉 두번만")를 어긴다.
    #
    #  플래그를 또 만들지 않고 **함수 자신이 말하게** 한다. 호출부가
    #  `getattr(fn, "owns_order", False)` 로 보고 flip 갈래를 건너뛴다.
    #
    #  ★dispatch 와 **같은 `_pair`** 를 읽는다 — 위 주석 참조.
    #   「봉인해야 하는가」와 「순서를 소유하는가」는 다른 물음이다.
    judge_fn.owns_order = _cross_model_order  # type: ignore[attr-defined]
    # ★이 판정이 **`readings` 를 남기는가** — 게이트 재롤은 그 칸으로만
    #  판단하므로, 안 남기는 판정기로 재롤하면 **돈을 쓰고 읽을 것이
    #  없다**(2026-09-20 Codex: 「발송 전에 검증하라」).
    #  ★스스로 주장하지 않고 **자기 스키마를 본다** — 선언만 받으면
    #   틀려도 안 막힌다.
    judge_fn.emits_readings = bool(          # type: ignore[attr-defined]
        "readings" in ((judge_schema or {}).get("required") or []))
    return judge_fn


def make_ref_pick_judge_fn(
    *,
    project_config: Optional[Dict[str, Any]] = None,
    step_tag: str = "structure_seed_ref_pick",
    opik_metadata: Optional[Dict[str, Any]] = None,
) -> Callable:
    """참조 사진 대비 **상대 비교** 판정을 multiroll JudgeFn 계약으로 잇는다.

    ★판정에 브리프를 주지 않는다. 지금까지는 브리프 대비 절대 점수였는데,
    참조 사진에 그 종류의 시설이 갖춘 것이 다 보여도 산출이 그것을 통째로
    빠뜨린 채 통과했다(실측: 참조는 실제 점포인데 산출은 빈 띠에 글자만).
    기준은 참조 사진 한 장이다.

    ★LLM 이 보는 칸은 승자와 사유 둘뿐이고, multiroll 이 요구하는
    {winner, ranking, verdicts} 는 코드가 채운다. 점수 칸을 주면 문턱이
    되살아나고, 칸을 늘릴수록 판정이 나빠진다.
    """
    from app.modules.llm.llm_client import call_structured
    from app.modules.pipeline.search_grounded_ref import (
        build_ref_pick_schema,
        load_ref_pick_system,
    )

    pc = {**(project_config or {}), step_tag: {"model": JUDGE_MODEL}}

    def _as_judge(labs: Sequence[str], winner: str,
                  reason: str) -> Dict[str, Any]:
        """승패를 1/0 으로만 옮긴다 — 정도를 매기지 않는다.

        좌우를 바꿔 두 번 물을 때(judge_flip) 두 번 다 이긴 쪽만 2점이 되고,
        한 번씩 이기면 동점이 돼 우선순위가 정한다.
        """
        rest = [lab for lab in labs if lab != winner]
        return {
            "winner": winner,
            "ranking": [winner, *rest],
            "verdicts": [
                {"label": lab,
                 "score": 1 if lab == winner else 0,
                 "verdict_ko": reason if lab == winner else ""}
                for lab in labs
            ],
        }

    def judge_fn(tag, prompt, labeled_refs, cand_paths, labels):
        labs = list(labels)
        if not labs:
            raise ValueError("ref_pick: 후보 라벨이 비었다")
        if len(labs) < 2:
            # 비교할 상대가 없다 — 유료 호출 없이 그 후보가 그대로 간다.
            return _as_judge(labs, labs[0], "후보가 하나뿐 — 비교 없음")
        parts: List[Dict[str, Any]] = ref_parts(labeled_refs)
        for lab, p in zip(labs, cand_paths):
            parts.append({"type": "text", "text": f"{lab}:"})
            parts.append(png_part(p))
        data = call_structured(
            step_tag,
            load_ref_pick_system(label_list=", ".join(labs)),
            parts,
            build_ref_pick_schema(labs),
            project_config=pc, schema_name=step_tag,
            opik_metadata=opik_metadata,
        )
        winner = str(data.get("winner") or "")
        if winner not in labs:
            # 조용히 첫 후보로 떨어뜨리지 않는다 — 판정이 안 된 것을 판정된
            # 것으로 기록하면 어느 경로가 이겼는지가 거짓이 된다.
            raise ValueError(
                f"ref_pick: 판정이 라벨 밖을 골랐다 {winner!r} (가능 {labs})")
        return _as_judge(labs, winner, str(data.get("reason_ko") or ""))

    return judge_fn


def make_gemini_critique_fn(
    *,
    critique_sys: str,
    critique_schema: Dict[str, Any],
    project_config: Optional[Dict[str, Any]] = None,
    step_tag: str = "multiroll_critique",
    opik_metadata: Optional[Dict[str, Any]] = None,
    ref_gate: bool = False,
) -> Callable:
    from app.modules.llm.llm_client import call_structured

    pc = {**(project_config or {}), step_tag: {"model": JUDGE_MODEL}}

    def critique_fn(tag, prompt, labeled_refs, image_path):
        parts: List[Dict[str, Any]] = [
            {"type": "text", "text": "THE PROMPT:\n" + prompt}
        ]
        parts += ref_parts(labeled_refs)
        parts.append({"type": "text", "text": "Photograph to examine:"})
        parts.append(png_part(image_path))
        if ref_gate:
            parts.append(ref_index_part(labeled_refs))
        return call_structured(
            step_tag, critique_sys, parts, critique_schema,
            project_config=pc, schema_name=step_tag,
            opik_metadata=opik_metadata,
        )

    return critique_fn


def make_gq_critique_fn(
    *,
    critique_schema: Dict[str, Any],
    project_config: Optional[Dict[str, Any]] = None,
    step_tag: str = "multiroll_critique",
    opik_metadata: Optional[Dict[str, Any]] = None,
    pack_version: str = GQ_CRITIQUE_PACK_VERSION,
    ref_gate: bool = False,
) -> Callable:
    """G+Q 수정 흐름 critique — Qwen 관찰 → Gemini 취합 2단 (2026-08-10 확정).

    CritiqueFn 시그니처 동일 — 하류(`_critique_and_fix` 의 issue partition·
    fix 조립·fix-rejudge·nb2)는 무변경이다. 현행 단일 critique 는 관찰과
    수정문 작성을 겸임하는데, 여기서는 역할을 가른다:

    1. **Qwen 관찰**: 무엇이 잘못됐는가만(수정문 없음 — 실측상 관찰·셈이
       강점, `matches_brief` 는 못 믿는 심판이라 관찰자 자리가 맞다).
    2. **관찰 0건 = 즉시 무결 반환** — Gemini 호출 생략(유료 1콜 절약).
       "Qwen 단독 관찰" 합의의 의미: Qwen 이 못 본 결함은 이 흐름에서
       고치지 않는다.
    3. **Gemini 취합**: 관찰을 이미지와 대조해 기각할 수 있고, 채택분만
       현행 critique 계약(issues: fix_en/unfixable/needs_regeneration)으로
       옮긴다 — 하류 소비 계약 불변.

    반환 dict 에 `qwen_observations`(관찰 원본)를 병기한다 —
    `record["critique"]` 에 접혀, 취합이 무엇을 기각했는지 대조로 추적된다.
    """
    from app.modules.llm.llm_client import call_structured
    from app.modules.llm.qwen_vlm_client import ask_qwen_structured
    from app.modules.pipeline.multiroll_select import build_gq_observe_schema
    from app.modules.prompt_loader import load_prompt

    resolved = resolve_judge_pack_version(pack_version)
    observe_sys = load_prompt(
        JUDGE_MODULE, "gq_observe_sys", version=resolved)
    compose_sys = load_prompt(
        JUDGE_MODULE, "gq_compose_sys", version=resolved)
    observe_schema = build_gq_observe_schema()
    compose_tag = f"{step_tag}_compose"
    pc = {**(project_config or {}), compose_tag: {"model": JUDGE_MODEL}}

    def critique_fn(tag, prompt, labeled_refs, image_path):
        parts: List[Dict[str, Any]] = [
            {"type": "text", "text": "THE PROMPT:\n" + prompt}
        ]
        parts += ref_parts(labeled_refs)
        parts.append({"type": "text", "text": "Photograph to examine:"})
        parts.append(png_part(image_path))
        obs = ask_qwen_structured(
            f"{step_tag}_observe", observe_sys, parts, observe_schema,
            opik_metadata=opik_metadata,
        )
        observations = list(obs.get("observations") or [])
        if not observations:
            return {"issues": [], "qwen_observations": []}
        compose_parts = list(parts)
        if ref_gate:
            compose_parts.append(ref_index_part(labeled_refs))
        # ★취합이 받을 수 있는 칸만 보여 준다 — 안 받을 칸을 보여 주면
        #  모델이 따라 내고 스키마가 거부한다(실측 254 중 76건 실패).
        compose_parts.append(
            observations_part(observations, critique_schema))
        data = call_structured(
            compose_tag, compose_sys, compose_parts, critique_schema,
            project_config=pc, schema_name=compose_tag,
            opik_metadata=opik_metadata,
        )
        out = dict(data)
        out["qwen_observations"] = observations
        return out

    return critique_fn


def make_qk_critique_fn(
    *,
    critique_schema: Dict[str, Any],
    project_config: Optional[Dict[str, Any]] = None,
    opik_metadata: Optional[Dict[str, Any]] = None,
    step_tag: str = "multiroll_critique",
    pack_version: str = QK_CRITIQUE_PACK_VERSION,
    ref_gate: bool = False,
) -> Callable:
    """QK 수정 흐름 critique — Gemini 관찰 → Qwen 취합 2단.

    `make_gq_critique_fn` 과 같은 2단 계약(관찰 0건=즉시 무결 반환·취합이
    관찰을 이미지 대조로 기각 가능·하류 소비 계약 불변) — **자리를 반전**
    한다: 관찰(보조 자리)=Gemini(기존 G+Q 의 우선 모델), 취합(메인 자리)=
    Qwen(DashScope 직결). 2026-08-12 사용자 재결정 — 처음 설계(Kimi 관찰)
    는 OpenRouter 지연 실측(관찰 평균 310s)으로 보류.

    팩은 QK 도입판 v9(STAGING 관찰 축 포함)가 기본 — QK 는 새 체계라 물려
    받을 구 지문이 없고, GQ(v8 고정)의 지문은 1 byte 도 건드리지 않는다.

    반환 dict 에 `observer_observations`(관찰 원본)+`observer_model` 을
    병기한다 — 관찰자가 Qwen 이 아니므로 GQ 의 `qwen_observations` 키를
    재사용하지 않는다(기록 오독 방지).
    """
    from app.modules.llm.llm_client import call_structured
    from app.modules.llm.qwen_vlm_client import ask_qwen_structured
    from app.modules.pipeline.multiroll_select import build_gq_observe_schema
    from app.modules.prompt_loader import load_prompt

    resolved = resolve_judge_pack_version(pack_version)
    observe_sys = load_prompt(
        JUDGE_MODULE, "gq_observe_sys", version=resolved)
    compose_sys = load_prompt(
        JUDGE_MODULE, "gq_compose_sys", version=resolved)
    observe_schema = build_gq_observe_schema()
    observe_tag = f"{step_tag}_observe"
    pc = {**(project_config or {}), observe_tag: {"model": JUDGE_MODEL}}

    def critique_fn(tag, prompt, labeled_refs, image_path):
        parts: List[Dict[str, Any]] = [
            {"type": "text", "text": "THE PROMPT:\n" + prompt}
        ]
        parts += ref_parts(labeled_refs)
        parts.append({"type": "text", "text": "Photograph to examine:"})
        parts.append(png_part(image_path))
        # 관찰자도 Gemini 로 봉인 (Codex BLOCK-2): safety 강등으로 GPT 가
        # 관찰했는데 observer_model='gemini-pro' 로 영속되는 오귀속 차단.
        # 실패는 삼키지 않고 전파한다(관찰자 대체 없음 — 기록 정직성).
        obs = call_structured(
            observe_tag, observe_sys, parts, observe_schema,
            project_config=pc, schema_name=observe_tag,
            opik_metadata=opik_metadata, enable_fallback=False,
        )
        observations = list(obs.get("observations") or [])
        if not observations:
            return {"issues": [], "observer_observations": [],
                    "observer_model": JUDGE_MODEL}
        compose_parts = list(parts)
        if ref_gate:
            compose_parts.append(ref_index_part(labeled_refs))
        # ★취합이 받을 수 있는 칸만 보여 준다 — 안 받을 칸을 보여 주면
        #  모델이 따라 내고 스키마가 거부한다(실측 254 중 76건 실패).
        compose_parts.append(
            observations_part(observations, critique_schema))
        data = ask_qwen_structured(
            f"{step_tag}_compose", compose_sys, compose_parts,
            critique_schema, opik_metadata=opik_metadata,
        )
        out = dict(data)
        # ── severity 재결속 (2026-08-13 Codex R1 BLOCK-1) ────────────
        # 심각도의 SOT 는 관찰자다. 취합(Qwen)이 major 를 critical 로
        # 승격하면 유료 fix 가 도로 발동한다(S39sh4 부류 재발) — 산문
        # 규칙이 아니라 코드가 지킨다: 각 이슈의 observation_index 로
        # 관찰 원값을 되찾아 severity 를 덮어쓰고, 인덱스가 실제 관찰을
        # 가리키지 않는 이슈(발명·번호 오류)는 fail-closed 로 버린다.
        # severity 미사용 스키마(구 경로 v7/GQ v8)는 이 블록을 안 탄다.
        issues = list(out.get("issues") or [])
        if any("severity" in i for i in issues if isinstance(i, dict)):
            bound: List[Dict[str, Any]] = []
            dropped: List[Dict[str, Any]] = []
            rebound = 0
            for issue in issues:
                idx = issue.get("observation_index")
                if not isinstance(idx, int) or isinstance(idx, bool) \
                        or not (0 <= idx < len(observations)):
                    dropped.append(issue)
                    continue
                obs_sev = (observations[idx] or {}).get("severity")
                if obs_sev and issue.get("severity") != obs_sev:
                    issue = {**issue, "severity": obs_sev}
                    rebound += 1
                bound.append(issue)
            out["issues"] = bound
            if dropped:
                out["compose_dropped_unbound"] = dropped
            if rebound:
                out["compose_severity_rebound_count"] = rebound
        out["observer_observations"] = observations
        out["observer_model"] = JUDGE_MODEL
        return out

    return critique_fn


def make_gg46_critique_fn(
    *,
    critique_schema: Dict[str, Any],
    project_config: Optional[Dict[str, Any]] = None,
    opik_metadata: Optional[Dict[str, Any]] = None,
    step_tag: str = "multiroll_critique",
    pack_version: str = GG46_CRITIQUE_PACK_VERSION,
    ref_gate: bool = False,
) -> Callable:
    """G+G46 수정 흐름 critique — **양쪽 관찰**(Gemini+grok-4.6) → Gemini
    취합 2단 (2026-08-13 사용자 확정 "수정사항을 취합해서").

    GQ/QK 와 같은 2단 계약(관찰 0건=즉시 무결 반환·취합이 관찰을 이미지
    대조로 기각 가능·하류 소비 계약 불변)에서 **관찰자가 둘**이다:
    파일럿 실측(23샷)에서 두 관찰자의 강점이 달랐다 — grok 만 S64sh4
    좌석 물리를, 관찰 밀도는 gemini 가 앞섰다. 병합=관찰 합집합이라 어느
    한쪽 눈만으로는 못 잡던 결함이 취합 검증대에 오른다.

    취합자=Gemini 인 이유(동등 원칙과의 관계 명문): 취합은 승자를 정하는
    판정이 아니라 관찰을 사진과 대조해 기각·번역하는 역할이라 동등 계약
    밖이다. grok-4.6 취합은 결함 샷마다 왕복 ~80-140s·~$0.04 를 더 내는데
    파일럿에서 취합 품질 우위 근거가 없다. severity SOT 는 어차피 관찰자
    (재결속 코드가 강제)라 취합자 선택이 심각도를 좌우하지 않는다.

    계약 세부:
    - 병합 목록 = [Gemini 관찰..., grok 관찰...] (모델 슬롯 순서 고정) —
      compose 의 `observation_index` 와 severity 재결속이 **병합 목록**
      기준으로 왕복한다.
    - 한쪽 관찰 실패 = 생존 관찰로 진행 + `observer_failed` 기록 (판정
      `single_*` route 관례 동형 — 기록이 정직하면 강등이 사고가 아니다).
      양쪽 실패 = 마지막 원인을 달아 전파.
    - 관찰자·취합자 Gemini 호출은 enable_fallback=False 봉인 (QK BLOCK-2
      관례 — GPT 강등 판정이 Gemini 로 영속되는 오귀속 차단).
    - 반환 dict: `observer_observations`=병합 목록(인덱스 SOT),
      `observer_models`, `observer_counts`, (있을 때만) `observer_failed`.
      GQ(`qwen_observations`)·QK(`observer_model` 단수) 키와 구분된다.
    """
    from app.modules.llm.llm_client import call_structured
    from app.modules.llm.openrouter_vlm_client import (
        ask_openrouter_structured,
    )
    from app.modules.pipeline.multiroll_select import build_gq_observe_schema
    from app.modules.prompt_loader import load_prompt

    from app.core.config import settings

    resolved = resolve_judge_pack_version(pack_version)
    observe_sys = load_prompt(
        JUDGE_MODULE, "gq_observe_sys", version=resolved)
    compose_sys = load_prompt(
        JUDGE_MODULE, "gq_compose_sys", version=resolved)
    observe_schema = build_gq_observe_schema()

    def _require_grok_slot() -> None:
        """OpenRouter·grok 확인 — **부를 때** 건다 (2026-08-29 Codex BLOCK-1).

        종전에는 선정 resolver 가 이 열쇠까지 요구했는데, 2026-08-29 부터
        선정은 Gemini+GPT 라 그것을 안 쓴다. 안 쓰는 것을 거기서 요구하면
        정상 구성이 422 로 막힌다.

        ★그렇다고 **만드는 자리**로 옮기면 안 된다 — 이 팩토리는 수정
        단계가 꺼져 있어도 만들어진다(`still_recipe_service.py:1489`
        는 `gg46_judge_on` 으로만 감싼다). 그러면 같은 결함을 자리만
        옮겨 되풀이한다. 그래서 **실제로 관찰을 시작하기 직전**에 건다.

        조용히 Gemini 단독 관찰로 내려가지 않는다 — 「양쪽이 봤다」가
        거짓이 되고, 그 거짓 위에서 수정 여부가 정해진다.
        """
        # ★[2026-09-08] 둘째 관찰자가 **grok → gpt-6-astra(high)** 로 바뀌었다.
        #  그래서 확인하는 것도 바뀐다 — 안 쓰는 OpenRouter 키를 요구하면
        #  멀쩡한 구성이 422 로 막힌다(「preflight 는 소비처 옆에」라는 이
        #  함수의 원래 취지 그대로다).
        from app.core.errors import AppError
        from app.core.openai_keys import has_openai_key

        if has_openai_key() and (settings.openai_model or "").strip():
            return
        raise AppError(
            code="multiroll.gg46_critique_unconfigured",
            message=(
                "수정 관찰의 둘째 자리가 gpt-6-astra(high) 인데 OpenAI 키 또는 "
                "OPENAI_MODEL 이 비어 있다 — 키가 없으면 alias 가 Router 에 "
                "안 붙어 조용히 한쪽 눈으로 보고 「양쪽이 봤다」고 적게 된다. "
                "키·모델을 채우거나 수정 단계를 내려라 (fail-closed)"
            ),
            status_code=422,
        )

    # ★2026-09-08 사용자 지시 — 결함 관찰 둘째 자리도 **grok → gpt-6-astra(high)**.
    #  「즉, gemini + gpt-6-astra(high)」. OpenRouter 를 안 거치고 Router 의
    #  `gpt-high` alias 로 간다(같은 물리 모델, 강도만 high).
    observer_models = [JUDGE_MODEL, SELECT_JUDGE_MODEL_2]
    observe_tag_g = f"{step_tag}_observe"
    # ★이름에 모델을 박지 않는다 — 둘째 관찰자가 바뀔 때마다 태그가 거짓이
    #  된다(`grok46` 이 실제로 그랬다).
    observe_tag_x = f"{step_tag}_observe_2"
    compose_tag = f"{step_tag}_compose"
    pc = {**(project_config or {}), observe_tag_g: {"model": JUDGE_MODEL},
          compose_tag: {"model": JUDGE_MODEL}}

    def critique_fn(tag, prompt, labeled_refs, image_path):
        parts: List[Dict[str, Any]] = [
            {"type": "text", "text": "THE PROMPT:\n" + prompt}
        ]
        parts += ref_parts(labeled_refs)
        parts.append({"type": "text", "text": "Photograph to examine:"})
        parts.append(png_part(image_path))
        _require_grok_slot()
        per_model: Dict[str, List[Dict[str, Any]]] = {}
        failed: List[str] = []
        last_exc: Optional[BaseException] = None

        def _observe(model: str) -> List[Dict[str, Any]]:
            if model == JUDGE_MODEL:
                obs = call_structured(
                    observe_tag_g, observe_sys, parts, observe_schema,
                    project_config=pc, schema_name=observe_tag_g,
                    opik_metadata=opik_metadata, enable_fallback=False,
                )
            elif model.startswith(OPENROUTER_JUDGE_PREFIX):
                obs = ask_openrouter_structured(
                    observe_tag_x, observe_sys, parts, observe_schema,
                    model=model[len(OPENROUTER_JUDGE_PREFIX):],
                    max_tokens=8000,
                    opik_metadata=opik_metadata,
                )
            else:
                # ★Router alias(`gpt-high`) — 첫 관찰자와 **같은 길**이다.
                #  fallback 은 끈다: 강등되면 기록은 gpt-high 인데 실제로는
                #  다른 모델이 본 것이 되어 「양쪽이 봤다」가 거짓이 된다.
                obs = call_structured(
                    observe_tag_x, observe_sys, parts, observe_schema,
                    project_config={**pc, observe_tag_x: {"model": model}},
                    schema_name=observe_tag_x,
                    opik_metadata=opik_metadata, enable_fallback=False,
                )
            return list(obs.get("observations") or [])

        # ★두 관찰자를 **동시에** 부른다 (2026-08-29 사용자 지시 "2, 4, 8
        #  병렬로"). 둘은 같은 `parts`(지시문 + 참조 전부 + 검사할 사진)를
        #  각자 보고 각자 답할 뿐 서로를 안 본다. **취합은 그 뒤**다 —
        #  관찰 결과가 입력이라 순서가 강제된다.
        #
        # ★결과는 **완료 순서가 아니라 `observer_models` 슬롯 순서**로 모은다.
        #  아래 병합 목록의 인덱스가 `observation_index` 계약이고 severity
        #  재결속이 그 인덱스로 왕복한다 — 순서가 흔들리면 심각도가 엉뚱한
        #  이슈에 붙는다.
        #
        # ★ContextVar 셋을 명시 전파한다(예산·capture·trace). 롤 생성 병렬과
        #  같은 처리이고, 하나라도 빠뜨리면 그 축만 조용히 무너진다.
        from concurrent.futures import ThreadPoolExecutor

        from app.core.image_call_budget import bind_current_budget
        from app.core.send_ledger import bind_current_ledger
        from app.modules.llm.opik_trace import bind_current_trace
        from app.services.image_capture.context import (
            bind_current_generation_context,
        )

        # ★**발송 장부도 나른다** (2026-09-20 Codex BLOCK). 안 실으면 이
        #  pool 안의 판정 호출이 통째로 안 잡혀 **「관측 0」**이 된다 —
        #  롤 팬아웃만 실어서는 **판정 pool 에 전파되지 않는다**.
        _bound = bind_current_budget(
            bind_current_ledger(
                bind_current_generation_context(
                    bind_current_trace(_observe))))
        with ThreadPoolExecutor(max_workers=len(observer_models)) as _pool:
            _futs = {m: _pool.submit(_bound, m) for m in observer_models}
        for model in observer_models:
            try:
                per_model[model] = _futs[model].result()
            except Exception as exc:  # noqa: BLE001
                last_exc = exc
                failed.append(model)
                logger.warning(
                    "G+G46 관찰: %s 실패 — 남은 관찰자로 진행: %r",
                    model, exc)
        if len(failed) == len(observer_models):
            raise RuntimeError("G+G46 관찰 전건 실패") from last_exc
        # 병합 순서=모델 슬롯 순서 고정 — 인덱스가 계약이다.
        observations: List[Dict[str, Any]] = []
        for model in observer_models:
            observations.extend(per_model.get(model, []))
        base_record = {
            "observer_observations": observations,
            "observer_models": list(observer_models),
            "observer_counts": {
                m: len(per_model.get(m, [])) for m in observer_models},
        }
        if failed:
            base_record["observer_failed"] = failed
        if not observations:
            return {"issues": [], **base_record}
        compose_parts = list(parts)
        if ref_gate:
            compose_parts.append(ref_index_part(labeled_refs))
        # ★같은 거르기를 쓰되 **헤더 문구는 그대로 둔다** — 이 경로는
        #  관찰자가 둘이라 그 사실을 말해 주어야 한다. 문구를 바꾸면
        #  나가는 프롬프트가 달라진다.
        compose_parts.append(observations_part(
            observations, critique_schema,
            header=("OBSERVATIONS (concatenated from TWO independent "
                    "visual inspectors — entries may report the same "
                    "fault twice; verify each against the photograph "
                    "before adopting it, and adopt each distinct fault "
                    "at most once):")))
        data = call_structured(
            compose_tag, compose_sys, compose_parts, critique_schema,
            project_config=pc, schema_name=compose_tag,
            opik_metadata=opik_metadata, enable_fallback=False,
        )
        out = dict(data)
        # ── severity 재결속 (QK Codex R1 BLOCK-1 관례 이식) ────────────
        # 심각도의 SOT 는 관찰자다 — 취합이 승격/강등하거나 관찰에 없는
        # 이슈를 발명하면 코드가 병합 목록 인덱스로 원값을 되찾아 덮어
        # 쓰고, 미등록 인덱스는 fail-closed 로 버린다.
        issues = list(out.get("issues") or [])
        if any("severity" in i for i in issues if isinstance(i, dict)):
            bound: List[Dict[str, Any]] = []
            dropped: List[Dict[str, Any]] = []
            rebound = 0
            for issue in issues:
                idx = issue.get("observation_index")
                if not isinstance(idx, int) or isinstance(idx, bool) \
                        or not (0 <= idx < len(observations)):
                    dropped.append(issue)
                    continue
                obs_sev = (observations[idx] or {}).get("severity")
                if obs_sev and issue.get("severity") != obs_sev:
                    issue = {**issue, "severity": obs_sev}
                    rebound += 1
                bound.append(issue)
            out["issues"] = bound
            if dropped:
                out["compose_dropped_unbound"] = dropped
            if rebound:
                out["compose_severity_rebound_count"] = rebound
        out.update(base_record)
        return out

    return critique_fn


def make_gpt_composition_critique_fn(
    *,
    critique_schema: Dict[str, Any],
    project_config: Optional[Dict[str, Any]] = None,
    step_tag: str = "multiroll_gpt_composition",
    opik_metadata: Optional[Dict[str, Any]] = None,
    pack_version: str = GPT_COMPOSITION_PACK_VERSION,
    ref_gate: bool = False,
) -> Callable:
    """GPT 구도 전담 critique 어댑터 (E2E11 fix③, 사용자 확정).

    CritiqueFn 시그니처 동일 — Gemini critique 와 별개로 선정본의 **구도
    위반만** 검사(sys 계약이 정체성/재질/조명 OUT OF SCOPE 봉인). 반환
    issues 는 Gemini issues 와 합산돼 하나의 수정 프롬프트로 i2i 된다.
    모델=GPT LVM(모델 분업: 이미지 검증·구도=GPT).
    """
    from app.modules.llm.llm_client import call_structured
    from app.modules.prompt_loader import load_prompt

    comp_sys = load_prompt(
        JUDGE_MODULE, "gpt_composition_sys",
        version=resolve_judge_pack_version(pack_version),
    )
    pc = {**(project_config or {}), step_tag: {"model": GPT_COMPOSITION_MODEL}}

    def composition_fn(tag, prompt, labeled_refs, image_path):
        parts: List[Dict[str, Any]] = [
            {"type": "text",
             "text": "THE GENERATION CONTRACT:\n" + prompt}
        ]
        parts += ref_parts(labeled_refs)
        parts.append(
            {"type": "text", "text": "Selected photograph to inspect:"})
        parts.append(png_part(image_path))
        # 참조 선별 — 이 갈래도 칸을 채워야 한다. 안 채우면 합산 목록에
        # 칸 없는 지적이 섞여 `select_fix_refs` 가 통째로 거르기를 그만두고
        # (fail-open), 지문에는 "선별 켜짐"으로 남아 조용히 무력해진다.
        if ref_gate:
            parts.append(ref_index_part(labeled_refs))
        return call_structured(
            step_tag, comp_sys, parts, critique_schema,
            project_config=pc, schema_name=step_tag,
            opik_metadata=opik_metadata,
        )

    return composition_fn
