#!/usr/bin/env python3
"""새 선정 판정(Gemini 정순 + GPT Sol 역순)을 **프로덕션 조립부 그대로** 한 번.

## 왜 필요한가 (Codex Q3)

오늘 여섯 모델 비교는 창구를 OpenRouter 로 통일해서 쟀다. 그런데
프로덕션의 `gpt` alias 는 **OpenAI 직결 Router** 다 — 같은 경로가 아니다.
「OpenRouter 로 GPT 가 이미지를 봤다」는 것은 「프로덕션 alias 로도 된다」의
증거가 아니다. 그래서 `make_gemini_judge_fn` 을 그대로 불러 실제로 태운다.

여기서 확인하는 것:
  · GPT alias 가 이미지 파트 + 판정 스키마를 받는가
  · route·slot_winner 가 제대로 남는가
  · **물리 호출이 몇 번 나갔는가** — `litellm.Router.completion` 을
    클래스 수준에서 감싸 센다.

## ★「정확히 두 번」이라 쓰지 않는다 (2026-08-29 Codex BLOCK-3)

종전 이 글은 「두 슬롯이 정확히 두 번 나가는가」를 확인한다고 적었는데,
실제로 센 것은 **논리 슬롯 두 개**뿐이었다. 그 사이에 OpenAI 키 브로커가
429 로 primary→secondary 를 갈아 끼우면 물리 호출이 하나 더 나간다 —
실제로 첫 주행에서 그 전환 로그가 찍혔다. `num_retries=0` 은 **Router
자체의 재시도**만 봉인하지 키 브로커 전환은 못 막는다.

그래서 이제 `Router.completion` 진입을 직접 센다. 키 전환으로 Router 가
다시 지어져도 **클래스 수준** 래퍼라 잡힌다.

★기본은 **dry** 다. 실제로 돈을 쓰려면 `--run` 을 붙인다.

usage: select_judge_smoke.py <project_id> <episode_id> --tag=S1sh4 --run
"""
from __future__ import annotations

import json
import pathlib
import sys
import time

sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[2]))
ROOT = pathlib.Path(__file__).resolve().parents[3]


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).
    tag, dry = "", "--run" not in sys.argv
    for a in sys.argv[1:]:
        if a.startswith("--tag="):
            tag = 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, GG46_SELECT_POLICY_VERSION,
        make_gemini_judge_fn, resolve_judge_texts,
        resolve_select_judge_model_physical, resolve_select_judge_models,
        select_judge_pair_kind,
    )
    from app.modules.pipeline.multiroll_select import (
        build_judge_schema, roll_labels,
    )

    models = resolve_select_judge_models()
    print(f"선정 슬롯 : {models}")
    print(f"물리 쌍   : {resolve_select_judge_model_physical()}")
    print(f"갈래      : {select_judge_pair_kind(models)}")
    print(f"정책      : {GG46_SELECT_POLICY_VERSION}")

    rdir = (pathlib.Path(settings.projects_dir) / project_id / "images"
            / episode_id / "scene" / "recipe")
    rec = json.loads((rdir / "records.json").read_text()).get(tag)
    if not isinstance(rec, dict):
        raise SystemExit(f"★{tag} 을 못 찾았다 — 「없다」로 읽지 마라")
    labels = roll_labels(2)
    cands = [rdir / f"{tag}_{x.lower()}.png" for x in labels]
    if any(not p.is_file() for p in cands):
        raise SystemExit(f"★후보 이미지가 없다: {[str(p) for p in cands]}")
    prompt = ((rec.get("roll_prompts") or {}).get(labels[0]) or "")
    if not prompt:
        raise SystemExit(f"★{tag} 의 roll 프롬프트가 비었다")

    texts = resolve_judge_texts(2, "judge_still", STILL_JUDGE_PACK_VERSION)
    judge_fn = make_gemini_judge_fn(
        judge_sys=texts["judge_sys"],
        judge_schema=build_judge_schema(labels, with_physics=True),
        step_tag="still_recipe_judge",
    )
    print(f"owns_order: {getattr(judge_fn, 'owns_order', '(없음)')}")
    print(f"{tag} · 프롬프트 {len(prompt):,}자 · 후보 {len(cands)}장")
    if dry:
        print("\n기본이 dry 라 호출 0 — 실제로 돌리려면 `--run`")
        return 0

    # ★물리 호출을 센다 — 논리 슬롯 수와 다를 수 있다(키 브로커 전환).
    import threading

    import litellm

    _orig_completion = litellm.Router.completion
    _n = {"calls": 0}
    _lock = threading.Lock()

    def _counted(self, *a, **k):
        with _lock:
            _n["calls"] += 1
        return _orig_completion(self, *a, **k)

    litellm.Router.completion = _counted
    t0 = time.time()
    try:
        res = judge_fn(tag, prompt, [], cands, labels)
    finally:
        litellm.Router.completion = _orig_completion
    dur = time.time() - t0

    cmo = res.get("cross_model_order") or {}
    print(f"\n═══ {dur:.1f}초 ═══")
    print(f"물리 호출    : Router.completion {_n['calls']}회 "
          f"(논리 슬롯 2개 — 다르면 키 브로커 전환이나 상위 재시도다)")
    print(f"route       : {cmo.get('route')}")
    print(f"슬롯별 승자 : {cmo.get('slot_winner')}")
    print(f"최종 winner : {res.get('winner')}  "
          f"all_fail={res.get('all_candidates_fail')}")
    for s in cmo.get("slots") or []:
        n = s.get("normalized") or {}
        hv = [f"{r.get('label')}: {'; '.join(r.get('hard_violations') or [])}"
              for r in (n.get("readings") or [])
              if r.get("hard_violations")]
        print(f"  · {s.get('model'):12s} {s.get('order'):8s} ok={s.get('ok')} "
              f"승자={n.get('winner')} 하드위반={hv or '없음'}")
    print(f"\n프로덕션 기록의 옛 선정: {rec.get('selected')} "
          f"(옛 판정자 = Gemini+Grok — 같은 후보라도 판정자가 다르다)")

    # ★남긴다 — 화면에만 찍고 사라지면 다시 재려고 또 돈을 쓴다.
    out = ROOT / "artifact" / "20260829_judge_bakeoff" / "production_smoke.json"
    out.parent.mkdir(parents=True, exist_ok=True)
    prev = json.loads(out.read_text()) if out.is_file() else []
    prev.append({
        "tag": tag, "duration_s": round(dur, 1),
        "router_completion_calls": _n["calls"],
        "logical_slots": len(models),
        "models": models,
        "physical": resolve_select_judge_model_physical(),
        "policy": GG46_SELECT_POLICY_VERSION,
        "route": cmo.get("route"),
        "slot_winner": cmo.get("slot_winner"),
        "winner": res.get("winner"),
        "all_candidates_fail": res.get("all_candidates_fail"),
        "slots": [{
            "model": s.get("model"), "order": s.get("order"),
            "ok": s.get("ok"),
            "winner": (s.get("normalized") or {}).get("winner"),
            "hard_by_label": {
                str(r.get("label")): list(r.get("hard_violations") or [])
                for r in ((s.get("normalized") or {}).get("readings") or [])},
        } for s in (cmo.get("slots") or [])],
        "old_record_selected": rec.get("selected"),
    })
    out.write_text(json.dumps(prev, ensure_ascii=False, indent=1))
    print(f"산출: {out}")
    return 0


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