#!/usr/bin/env python3
"""A/B 선정 판정을 **여러 최상위 모델에 동시에** 물어본다 — 임시 도구.

지금 판정은 Gemini 3.1 Pro + Grok 4.6 둘뿐이다. 이 도구는 같은 샷·같은
지시문·같은 스키마를 **정순 한 번씩** 여러 모델에 쓰레드로 던져, 누가
무엇을 고르고 무엇을 하드위반으로 올리는지 나란히 놓는다.

## 밝혀 적는 것

  · **창구를 OpenRouter 하나로 통일했다.** 프로덕션은 GPT·Gemini·Opus 를
    각자 네이티브 키로 부른다. 여기서 통일한 이유는 같은 전송·같은 JSON
    모드로 놓아야 「고른 것」을 견줄 수 있어서다. 그래서 **여기 시간은
    프로덕션 시간이 아니다** — 고른 것만 읽는다.
  · **reasoning 파라미터를 안 보낸다.** 각 모델의 기본값으로 돈다.
  · seed 는 받는 모델에만 보낸다(Opus 는 안 받는다). 어차피 판정 재현을
    보장하지 않으므로 이것으로 「고정됐다」고 쓰지 않는다.

★이 도구는 측정 전용이다. 프로덕션 VLM 자리는 gemini 3.1 pro + grok
최신 둘 그대로다 — 여기 결과로 배선을 바꾸지 않는다.

usage:
  judge_model_bakeoff.py <project_id> <episode_id> --tag=S1sh4 --run   (기본 dry)
                         [--models=a,b,c] [--out=<디렉토리>]
                         [--recipe-dir=<백업 recipe 경로>]

★`--recipe-dir` 를 주면 **그 폴더의 후보**를 판정한다. 주행 뒤 백업해 둔
 옛 후보로 offline rejudge 할 때 반드시 쓴다 — 안 주면 live 프로덕션의
 **새로 구운** 후보를 재판정하게 되어 실험이 무너진다.
"""
from __future__ import annotations

import json
import pathlib
import sys
import time
from concurrent.futures import ThreadPoolExecutor
from typing import Any, Dict, List

sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[2]))
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
import _refs  # noqa: E402
ROOT = pathlib.Path(__file__).resolve().parents[3]
OUT = ROOT / "artifact" / "20260829_judge_bakeoff" / "calls.json"
SEED = 20260829

# ★ID 는 OpenRouter 목록 조회로 확인한 것 그대로 — 손으로 지으면 400 이
#  나거나 엉뚱한 모델이 돈다. GLM 은 5.3 본체가 텍스트 전용이라 GLM-5
#  계열 유일한 네이티브 멀티모달인 5.3-flash 를 세운다.
MODELS = [
    ("google/gemini-3.1-pro-preview", "Gemini 3.1 Pro", True),
    ("x-ai/grok-4.6", "Grok 4.6", True),
    ("openai/gpt-5.6-sol", "GPT-5.6 Sol", True),
    ("anthropic/claude-opus-5", "Claude Opus 5", False),   # seed 안 받음
    ("moonshotai/kimi-k3", "Kimi K3", True),
    ("z-ai/glm-5.3-flash", "GLM-5.3 Flash", True),
]


def main() -> int:
    argv = [a for a in sys.argv[1:] if not a.startswith("--")]
    if len(argv) < 2:
        print(__doc__)
        return 2
    project_id, episode_id = argv[0], argv[1]
    # ★기본이 dry (Codex NON-BLOCK) — 돌리려면 `--run`
    tag, dry, only_models, recipe_dir = "", "--run" not in sys.argv, "", ""
    # ★참조를 **기본으로 붙인다** (2026-08-29 Codex BLOCK-1). 프로덕션
    #  `judge_fn` 은 `ref_parts(labeled_refs)` 로 샷마다 2~3장을 붙인다.
    #  안 붙이고 잰 결과를 옛 프로덕션 판정과 나란히 놓고 「판정자만
    #  갈랐다」고 읽으면 **판정자와 참조 유무가 함께 바뀐 대조**라 거짓이다.
    #  `--no-refs` 는 **참조 없는 probe** 를 일부러 잴 때만 쓴다 — 그때는
    #  기록에 `refs_attached=0` 이 남고 갤러리가 프로덕션 대조를 뺀다.
    attach_refs = "--no-refs" not in sys.argv
    # ★effort 를 **주면 그때만** 보낸다. 기본은 안 보냄 = 각 모델
    #  기본값 = 프로덕션이 실제로 도는 조건. 올려서 재는 것은 「같은
    #  effort 면 어떤가」라는 **다른 물음**이므로 기록에 남긴다.
    effort = ""
    outdir = OUT.parent
    for a in sys.argv[1:]:
        if a.startswith("--tag="):
            tag = a.split("=", 1)[1].strip()
        elif a.startswith("--models="):
            only_models = a.split("=", 1)[1].strip()
        elif a.startswith("--out="):
            outdir = pathlib.Path(a.split("=", 1)[1].strip())
        elif a.startswith("--recipe-dir="):
            recipe_dir = a.split("=", 1)[1].strip()
        elif a.startswith("--effort="):
            effort = a.split("=", 1)[1].strip()
    if not tag:
        raise SystemExit("--tag 이 필요하다")

    from app.core.config import settings
    from app.modules.pipeline.multiroll_gemini import (
        STILL_JUDGE_PACK_VERSION, png_part, ref_parts,
        resolve_judge_pack_version, resolve_judge_texts,
    )
    from app.modules.pipeline.multiroll_select import (
        build_judge_schema, normalize_flip_verdict, roll_labels,
    )

    # ★입력 recipe 디렉토리를 **밖에서 줄 수 있어야 한다** (Codex 지적).
    #  종전에는 live `projects_dir` 를 박아 놨다. 그러면 주행 **뒤**에
    #  돌렸을 때 백업해 둔 옛 후보가 아니라 **새로 구운 후보**를 재판정한다
    #  — 「같은 후보를 다른 판정자로」라는 실험이 통째로 무너진다.
    #  `--out` 은 결과 폴더만 바꿨지 입력은 안 바꿨다.
    rdir = (pathlib.Path(recipe_dir) if recipe_dir else
            (pathlib.Path(settings.projects_dir) / project_id / "images"
             / episode_id / "scene" / "recipe"))
    if not (rdir / "records.json").is_file():
        raise SystemExit(f"★records.json 이 없다: {rdir}")
    print(f"입력 recipe: {rdir}"
          + ("   ★밖에서 준 경로" if recipe_dir else "   (live 프로덕션)"))
    recs = json.loads((rdir / "records.json").read_text())
    rec = recs.get(tag)
    if not isinstance(rec, dict):
        raise SystemExit(f"★{tag} 을 records 에서 못 찾았다 — 「없다」로 읽지 마라")
    labels = roll_labels(2)
    cands = [rdir / f"{tag}_{x.lower()}.png" for x in labels]
    missing = [str(p) for p in cands if not p.is_file()]
    if missing:
        raise SystemExit(f"★후보 이미지가 없다: {missing}")
    prompt = ((rec.get("roll_prompts") or {}).get(labels[0])
              or next(iter((rec.get("roll_prompts") or {}).values()), ""))
    if not prompt:
        raise SystemExit(f"★{tag} 의 roll 프롬프트가 비었다")

    texts = resolve_judge_texts(2, "judge_still", STILL_JUDGE_PACK_VERSION)
    schema = build_judge_schema(labels, with_physics=True)
    # ★fail-closed — 하나라도 못 풀면 여기서 멈춘다
    # ★사본을 줬으면 **recipe 안쪽 참조도 사본에서** 푼다
    #  (2026-08-29 Codex BLOCK-1 둘째 갈래). `prev_still` 은 recipe
    #  폴더 안의 다른 샷 `_sel.png` 이고 JIT 가 그것을 다시 굽는다.
    refs = (_refs.labeled_refs(rec.get("refs") or [],
                               snapshot_recipe=rdir if recipe_dir else None)
            if attach_refs else [])

    models = list(MODELS)
    if only_models:
        want = {x.strip() for x in only_models.split(",") if x.strip()}
        models = [m for m in models if m[0] in want]
        unknown = want - {m[0] for m in MODELS}
        if unknown:
            raise SystemExit(f"★내 목록에 없는 모델: {sorted(unknown)}")

    cm = rec.get("cross_model_order") or {}
    print(f"팩 judge={resolve_judge_pack_version(STILL_JUDGE_PACK_VERSION)} · "
          f"{tag} · 프롬프트 {len(prompt):,}자 · 후보 {len(cands)}장")
    print(f"프로덕션 선정 = {rec.get('selected')} · route={cm.get('route')} "
          f"· 슬롯별 {cm.get('slot_winner')}")
    print(f"참조 {len(refs)}장 "
          + ("(프로덕션과 같게 붙임)" if attach_refs
             else "★--no-refs — 참조 없는 probe 다. 프로덕션 판정과 "
                  "직접 견주지 마라"))
    for _lab, _p in refs:
        print(f"   · {_lab[:60]}… → {_p.name}")
    print(f"모델 {len(models)}개 × 정순 1회 = 논리 **{len(models)}콜**"
          f" (쓰레드 동시 · 모델마다 실패 시 재시도 1회 → 물리는 모델당 "
          f"최대 2콜, `physical_calls` 로 남는다 · SDK 안쪽 재시도는 껐다)"
          + (f" · effort={effort} ★올려서 잼" if effort
             else " · effort 미전송(각 모델 기본 = 프로덕션 조건)"))
    for mid, nm, _ in models:
        print(f"   · {nm:16s} {mid}")
    if dry:
        print("\n기본이 dry 라 호출 0 — 실제로 돌리려면 `--run`")
        return 0

    from app.modules.llm.openrouter_vlm_client import JSON_CLAUSE, _client

    sys_prompt = texts["judge_sys"] + JSON_CLAUSE.format(
        schema=json.dumps(schema, ensure_ascii=False, indent=2))
    parts = [{"type": "text", "text": texts.get("judge_prompt_header")
              or "THE PROMPT (all candidates were generated from this):"},
             {"type": "text", "text": prompt}]
    parts += ref_parts(refs)          # 프로덕션 judge_fn 과 같은 자리·순서
    for disp, src in zip(labels, cands):     # ★정순 — display=canonical
        parts.append({"type": "text", "text": f"Candidate {disp}:"})
        parts.append(png_part(src))
    identity = {x: x for x in labels}
    # ★SDK 안쪽 재시도를 끈다 (2026-08-29 Codex 재리뷰 BLOCK-2).
    #  `_client()` 는 `max_retries` 를 안 주므로 openai SDK 기본값 **2** 다
    #  — `create()` 한 번이 안에서 최대 3회 전송된다. 그러면 아래 바깥
    #  루프(2회)까지 합쳐 **최대 6회 나가는데 `physical_calls` 는 2** 로
    #  적힌다. 시간도 같이 거짓이 된다: `dur` 안에 SDK 의 재시도와 그
    #  backoff 잠이 섞여 들어간다.
    #  끄면 **바깥 시도 = 물리 전송**이라 수와 시간이 둘 다 정확해진다.
    #  대가는 일시 오류에 덜 버티는 것(최대 6회 → 2회)인데, 재는 도구에는
    #  버티기보다 **정확한 계수**가 맞다. 설정값은 기록에도 남긴다.
    _raw_client = _client()
    sdk_retries_before = getattr(_raw_client, "max_retries", None)
    client = _raw_client.with_options(max_retries=0)
    sdk_retries = getattr(client, "max_retries", None)
    if sdk_retries != 0:
        raise SystemExit(
            f"★SDK 재시도를 못 껐다 (max_retries={sdk_retries!r}) — 이대로면 "
            "`physical_calls` 가 실제 전송 수보다 적게 적힌다")
    print(f"SDK 재시도 {sdk_retries_before} → 0 "
          "(물리 전송 = 바깥 시도 수가 되게)")

    def run(job):
        mid, nm, has_seed = job
        extra: Dict[str, Any] = {"seed": SEED} if has_seed else {}
        if effort:
            extra["reasoning"] = {"effort": effort}
        err, payload, usage, dur = None, None, {}, 0.0
        raw = ""
        # ★「모델 N개 = N콜」이 아니다 (2026-08-29 Codex BLOCK-2).
        #  아래 루프는 실패하면 한 번 더 던진다 — 비-JSON 본문 재시도를
        #  실제로 봤다. `dur` 은 **마지막 시도**만 남아 첫 실패 시간이
        #  사라지므로, 물리 콜 수와 **실제로 걸린 시간의 합**을 따로 센다.
        #  ★위에서 SDK 재시도를 0 으로 껐으므로 이 수가 곧 전송 수다
        #  (안 끄면 `create()` 한 번이 안에서 3번까지 나가 이 수가 거짓이
        #   되고, 그 재시도와 backoff 잠이 `dur` 에도 섞인다).
        n_calls = [0]
        wall = [0.0]
        attempts: List[Dict[str, Any]] = []
        for attempt in (1, 2):
            t0 = time.time()
            n_calls[0] += 1
            try:
                resp = client.chat.completions.create(
                    model=mid,
                    messages=[{"role": "system", "content": sys_prompt},
                              {"role": "user", "content": parts}],
                    response_format={"type": "json_object"},
                    max_tokens=8000, extra_body=extra, timeout=900,
                )
                dur = time.time() - t0
                raw = resp.choices[0].message.content or ""
                payload = json.loads(raw or "{}")
                u = getattr(resp, "usage", None)
                if u is not None:
                    det = getattr(u, "completion_tokens_details", None)
                    usage = {
                        "prompt_tokens": getattr(u, "prompt_tokens", None),
                        "completion_tokens": getattr(
                            u, "completion_tokens", None),
                        "reasoning_tokens": getattr(det, "reasoning_tokens",
                                                    None) if det else None,
                    }
                err = None
                wall[0] += dur
                attempts.append({"n": attempt, "s": round(dur, 1),
                                 "ok": True})
                break
            except Exception as exc:            # noqa: BLE001
                dur = time.time() - t0
                wall[0] += dur
                err = f"{attempt}차: {exc!r}"[:300]
                attempts.append({"n": attempt, "s": round(dur, 1),
                                 "ok": False, "err": err[:120]})
                if attempt == 1:
                    time.sleep(3)
        canon = None
        if not err and not payload:
            err = "응답은 왔는데 판정 본문이 비었다 (빈 JSON)"
        if payload and not err:
            try:
                canon = normalize_flip_verdict(payload, identity, labels)
            except Exception as exc:            # noqa: BLE001
                err = f"판정 모양이 계약을 어겼다: {exc!r}"[:250]
        return {
            "model": mid, "name": nm, "tag": tag, "order": "forward",
            "effort_sent": effort or None,
            # ★논리 1콜이 아니다 — 재시도가 있으면 물리는 더 나간다
            #  (2026-08-29 Codex BLOCK-2). `duration_s` 는 **마지막 시도**
            #  뿐이므로 누적해서 걸린 시간을 따로 남긴다.
            "refs_attached": len(refs),
            # ★SDK 재시도를 0 으로 끄고 잰 수다 — 그래야 이 값이 실제
            #  HTTP 전송 수다. 설정을 같이 남겨 나중에 확인할 수 있게 한다
            #  (`_client()` 가 바뀌면 이 값이 0 이 아니게 된다).
            "physical_calls": n_calls[0],
            "sdk_max_retries": sdk_retries,
            # ★`provider_s` — 시도들의 **호출 시간 합**이다.
            #  재시도 사이 3초 backoff 는 안 들어간다. 그래서
            #  `wall_s` 라 부르지 않는다 (Codex BLOCK-4).
            "provider_s": round(wall[0], 1),
            "attempts": attempts,
            "duration_s": round(dur, 1), "usage": usage, "error": err,
            "winner": (canon or {}).get("winner"),
            "ranking": (canon or {}).get("ranking"),
            "all_candidates_fail": (canon or {}).get("all_candidates_fail"),
            "scores": {str(v.get("label")): v.get("score")
                       for v in ((canon or {}).get("verdicts") or [])},
            "reasons": {str(v.get("label")): v.get("reason_ko")
                        for v in ((canon or {}).get("verdicts") or [])},
            "hard_by_label": {
                str(r.get("label")): list(r.get("hard_violations") or [])
                for r in ((canon or {}).get("readings") or [])},
            "payload": payload, "raw_text": raw[:4000],
        }

    with ThreadPoolExecutor(max_workers=len(models)) as ex:
        rows = list(ex.map(run, models))

    print("\n═══ 정순 · 누가 무엇을 골랐나 ═══")
    print(f"{'모델':18s} {'승자':5s} {'all_fail':9s} {'하드위반':9s} "
          f"{'provider시간':>12s} {'추론토큰':>9s}")
    for r in rows:
        if r["error"]:
            print(f"{r['name']:18s} ✗ {r['error'][:80]}")
            continue
        if not r["winner"]:
            print(f"{r['name']:18s} ✗ 승자 없음 — 실패로 센다")
            continue
        nh = sum(len(v) for v in r["hard_by_label"].values())
        # ★`duration_s` 는 **마지막 시도**만이다 — 재시도가 있으면 첫 실패
        #  시간이 사라진다(2026-08-29 Codex BLOCK-4). 사람이 보는 자리에는
        #  **누적 provider 시간**과 **물리 콜 수**를 보인다.
        pc = r.get("physical_calls")
        ps = r.get("provider_s", r.get("wall_s"))
        print(f"{r['name']:18s} {str(r['winner']):5s} "
              f"{str(r['all_candidates_fail']):9s} {nh:>4}건     "
              f"{(ps if ps is not None else r['duration_s']):7.1f}초"
              f"{('×' + str(pc) + '콜') if (pc or 1) > 1 else '    '} "
              f"{str((r['usage'] or {}).get('reasoning_tokens')):>9s}")
    ok = [r for r in rows if not r["error"]]
    tally: dict = {}
    for r in ok:
        tally[r["winner"]] = tally.get(r["winner"], 0) + 1
    print(f"\n표: {tally}   (프로덕션 선정 {rec.get('selected')})")
    print("★표본은 모델당 한 판이다 — 백분율로 쓰지 않는다")

    outdir.mkdir(parents=True, exist_ok=True)
    (outdir / "calls.json").write_text(
        json.dumps(rows, ensure_ascii=False, indent=1))
    print(f"\n산출: {outdir/'calls.json'}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
