"""Qwen 3.8 Max VLM 판정 갤러리 — 넓게 물은 것과 좁게 물은 것을 나란히.

한 카드에 담는 것
  ⓪ 사용자가 육안으로 지목한 결함 (이 샷을 표본으로 삼은 이유)
  ① 넓게 — 열린 선정 판정. 후보 전부에 Qwen 이 쓴 3축 읽기 + 하드 위반 + 점수.
           Qwen 이 고른 것에 테두리, 파이프라인이 고른 것에 표식.
  ② 좁게 — 지목 결함 하나를 후보마다 개별 이미지로 물은 답.
           같은 질문을 그 샷의 후보 전부에 던졌으므로 **후보들이 서로 대조군**이다.
           지목된 후보에서만 다르게 답하면 탐지, 아무 데서나 결함을 보면 과탐지.

넓게만 보면 모델이 안 본 것을 안 본 채로 넘기고, 좁게만 보면 유도 질문이 되어
과탐지를 낳는다. 그래서 둘을 같은 화면에 둔다.

    .venv/bin/python build_qwen_gallery.py <outdir>
"""
from __future__ import annotations

import html
import json
import re
import sys
from pathlib import Path

ROOT = Path(__file__).resolve().parent.parent
PROJ = "e716bafb-24bb-42b7-aea0-fdb383844ee8"
EPI = "d6a9aa85-b75e-400c-980c-4ee7e876a15b"
RECIPE_WEB = f"/projects/{PROJ}/images/{EPI}/scene/recipe"

# 사용자가 육안으로 지목한 것 — 이 샷들을 표본으로 삼은 근거.
FLAGGED = {
    "S3sh6": "수정 단계가 서양 남자를 만들었다",
    "S13sh3": "핸들 림이 이중으로 겹친다 / _b 는 인물이 조수석에 있다",
    "S15sh5": "룸미러에 얼굴이 절반만 비친다",
    "S18sh5": "선정본·수정본 둘 다 좌석이 돌아가 있다",
    "S42sh4": "선정본에서 폰이 허공에 뜬다 (손이 있는 후보가 실재한다)",
    "S62sh4": "지폐가 미국 달러다 (한국 지폐 후보가 실재한다)",
    "S88sh6": "총구·시선 방향이 상대를 향하지 않는다",
}

CSS = """
:root{--bg:#101319;--card:#191d26;--line:#2b313d;--fg:#e7eaf0;--dim:#98a1b3;
--pick:#ffd166;--base:#5aa9ff;--win:#3ddc84;--lose:#ff6b6b;--warn:#ffa94d}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--fg);
font:14px/1.65 -apple-system,BlinkMacSystemFont,"Apple SD Gothic Neo",sans-serif}
header{position:sticky;top:0;z-index:9;background:#0b0d12ee;backdrop-filter:blur(8px);
border-bottom:1px solid var(--line);padding:14px 20px}
h1{margin:0 0 6px;font-size:17px}
.sum{color:var(--dim);font-size:13px}.sum b{color:var(--fg)}
.legend{margin-top:8px;font-size:12px;color:var(--dim)}
.sw{display:inline-block;width:11px;height:11px;border-radius:3px;
vertical-align:-1px;margin:0 4px 0 12px}
main{padding:18px 20px 80px;max-width:1700px;margin:0 auto}
.still{background:var(--card);border:1px solid var(--line);border-radius:12px;
padding:16px;margin-bottom:22px}
.hd{display:flex;gap:10px;align-items:baseline;flex-wrap:wrap;margin-bottom:4px}
.hd .id{font-size:16px;font-weight:700}
.tag{border-radius:5px;padding:2px 8px;font-size:11px;font-weight:700}
.t-flip{background:var(--warn);color:#101319}.t-same{background:#39404f;color:var(--dim)}
.t-fail{background:var(--lose);color:#fff}
.flag{font-size:13px;color:#ffd9a0;background:#2a2118;border-left:3px solid var(--warn);
padding:7px 11px;border-radius:0 6px 6px 0;margin:6px 0 14px}
.sec{font-size:11px;letter-spacing:.08em;color:var(--dim);text-transform:uppercase;
margin:16px 0 8px;border-top:1px solid var(--line);padding-top:11px}
.rolls{display:flex;gap:12px;flex-wrap:wrap;align-items:flex-start}
.roll{width:320px}
.roll img{width:100%;border-radius:7px;display:block;border:3px solid transparent}
.roll.pick img{border-color:var(--pick)}
.roll .cap{font-size:12.5px;margin-top:6px;font-weight:700;display:flex;
gap:6px;align-items:center;flex-wrap:wrap}
.chip{font-size:10.5px;font-weight:700;border-radius:4px;padding:1px 6px}
.c-pick{background:var(--pick);color:#101319}.c-base{background:var(--base);color:#101319}
.ax{font-size:11.5px;color:#c2cad8;margin-top:5px;line-height:1.55}
.ax b{color:var(--dim);font-weight:600}
.vio{font-size:11.5px;color:#ffb3b3;margin-top:5px}
.nar{border:1px solid var(--line);border-radius:8px;padding:10px 12px;margin-top:7px}
.nar.bad{border-color:#7a3b3b;background:#231517}
.nar.good{border-color:#2f5c40;background:#152119}
.nar .verd{font-size:12px;font-weight:700;margin-bottom:4px}
.nar.bad .verd{color:#ff9b9b}.nar.good .verd{color:var(--win)}
.nar .obs{font-size:11.5px;color:#c2cad8}
.q{font-size:13px;color:#cfe3ff;background:#141c28;border-left:3px solid var(--base);
padding:7px 11px;border-radius:0 6px 6px 0;margin-bottom:10px}
.why{font-size:13px;color:#c8cfdc;margin-top:9px}
.narrow-grid{display:flex;gap:12px;flex-wrap:wrap}
.narrow-grid>div{width:320px}
@media(max-width:900px){.roll,.narrow-grid>div{width:100%}}
"""


def esc(x) -> str:
    return html.escape(str(x or ""))


def key(s: str):
    m = re.findall(r"\d+", s)
    return (int(m[0]), int(m[1])) if len(m) >= 2 else (9999, 9999)


def main() -> None:
    outdir = Path(sys.argv[1] if len(sys.argv) > 1
                  else ROOT / "artifact/20260807_qwen38max_vlm")
    wide = json.loads((outdir / "qwen_select.json").read_text("utf-8"))
    npath = outdir / "qwen_narrow.json"
    narrow = json.loads(npath.read_text("utf-8")) if npath.exists() else {}

    stems = sorted((s for s in wide if wide[s].get("qwen")), key=key)
    n_flip = sum(1 for s in stems if wide[s].get("agrees_with_baseline") is False)
    model = next((v.get("meta", {}).get("model") for v in wide.values()
                  if v.get("meta")), "qwen3.8-max")

    P = ['<meta charset="utf-8">',
         "<title>Qwen 3.8 Max VLM 판정 — 넓게 · 좁게</title>",
         f"<style>{CSS}</style>", "<header>",
         "<h1>Qwen 3.8 Max — VLM 판정 실험 (넓게 물은 것 · 좁게 물은 것)</h1>",
         f'<div class="sum">모델 <b>{esc(model)}</b> · 표본 <b>{len(stems)}</b>샷'
         f' (사용자 육안 지목) · 파이프라인 선정과 달라짐 <b>{n_flip}</b></div>',
         '<div class="legend">'
         '<span class="sw" style="background:var(--pick)"></span>Qwen 이 고른 후보'
         '<span class="sw" style="background:var(--base)"></span>파이프라인이 고른 후보'
         ' · 좁게 묻기는 후보마다 <b>개별 이미지</b>로 물었다 — 후보들이 서로 대조군이다.'
         '</div></header><main>']

    for stem in stems:
        v = wide[stem]
        q = v["qwen"]
        pick, base = v.get("qwen_winner"), v.get("baseline_selected")
        agree = v.get("agrees_with_baseline")
        tag = ('<span class="tag t-flip">선정 뒤집힘</span>' if agree is False
               else '<span class="tag t-same">선정 동일</span>')
        if q.get("all_candidates_fail"):
            tag += '<span class="tag t-fail">후보 전부 실패 선언</span>'

        P.append(f'<section class="still"><div class="hd">'
                 f'<span class="id">{esc(stem)}</span>{tag}</div>')
        if FLAGGED.get(stem):
            P.append(f'<div class="flag">육안 지목 — {esc(FLAGGED[stem])}</div>')

        # ① 넓게
        P.append('<div class="sec">넓게 — 열린 선정 판정 (후보 전부를 한 번에 제시)</div>'
                 '<div class="rolls">')
        readings = {r["label"]: r for r in q.get("readings") or []}
        for lab in v.get("labels") or sorted(readings):
            r = readings.get(lab, {})
            chips = ""
            if lab == pick:
                chips += '<span class="chip c-pick">Qwen 선정</span>'
            if lab == base:
                chips += '<span class="chip c-base">파이프라인 선정</span>'
            vio = r.get("hard_violations") or []
            P.append(
                f'<div class="roll{" pick" if lab == pick else ""}">'
                f'<img src="{RECIPE_WEB}/{esc(stem)}_{lab.lower()}.png" loading="lazy">'
                f'<div class="cap">{esc(lab)} · {r.get("score", "?")}점{chips}</div>'
                f'<div class="ax"><b>방향</b> {esc(r.get("direction"))}</div>'
                f'<div class="ax"><b>내부 공간</b> {esc(r.get("built_space"))}</div>'
                f'<div class="ax"><b>엔티티</b> {esc(r.get("entities"))}</div>'
                + (f'<div class="vio"><b>하드 위반 {len(vio)}</b><br>'
                   + "<br>".join("· " + esc(x) for x in vio) + "</div>" if vio else "")
                + "</div>")
        P.append("</div>")
        if q.get("why"):
            P.append(f'<div class="why"><b>선정 사유</b> — {esc(q["why"])}</div>')

        # ② 좁게
        nv = narrow.get(stem)
        if nv and nv.get("per_label"):
            P.append('<div class="sec">좁게 — 지목 결함 하나를 후보마다 개별로 물음</div>'
                     f'<div class="q">Q. {esc(nv.get("question"))}</div>'
                     '<div class="narrow-grid">')
            for lab in nv.get("labels") or sorted(nv["per_label"]):
                r = nv["per_label"].get(lab, {})
                if r.get("error"):
                    P.append(f'<div><div class="nar"><div class="verd">{esc(lab)} — 호출 실패'
                             f'</div><div class="obs">{esc(r["error"])}</div></div></div>')
                    continue
                mb = r.get("matches_brief")
                cls = {True: "good", False: "bad", None: ""}[mb]
                mark = {True: "브리프와 일치", False: "브리프와 어긋남",
                        None: "판단 불가"}[mb]
                cnt = "" if r.get("count") is None else f' · 개수 {r["count"]}'
                P.append(
                    f'<div><div class="nar {cls}">'
                    f'<div class="verd">{esc(lab)} — {mark}{esc(cnt)}</div>'
                    f'<div class="obs"><b>답</b> {esc(r.get("answer_ko"))}</div>'
                    f'<div class="obs" style="margin-top:5px"><b>본 것</b> '
                    f'{esc(r.get("observation_ko"))}</div></div></div>')
            P.append("</div>")
        P.append("</section>")

    P.append("</main>")
    (outdir / "index.html").write_text("\n".join(P), "utf-8")
    print(f"갤러리: {outdir / 'index.html'}  ({len(stems)}샷)")


if __name__ == "__main__":
    main()
