CoolFace
Apppublic

developer-lunark/kaidol-thinking-experiment

sourceHugging Faceapache-2.0updated 8mo agoView on Hugging Face
0likes
app.py680 linesDownload Raw Back to root
1#!/usr/bin/env python32"""KAIdol A/B Test Arena - GPU Version with Real Model Inference"""3 4import gradio as gr5import random6import json7import uuid8import re9import gc10import os11from datetime import datetime12from functools import lru_cache13 14# GPU 추론 관련 (선택적 임포트)15TORCH_AVAILABLE = False16IMPORT_ERROR = None17torch = None18try:19    import torch as _torch20    torch = _torch21    from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig22    from peft import PeftModel23    TORCH_AVAILABLE = True24 25    # Debug info26    print("=" * 50)27    print(f"PyTorch version: {torch.__version__}")28    print(f"CUDA available: {torch.cuda.is_available()}")29    if torch.cuda.is_available():30        print(f"CUDA version: {torch.version.cuda}")31        print(f"GPU count: {torch.cuda.device_count()}")32        print(f"GPU name: {torch.cuda.get_device_name(0)}")33    else:34        print("CUDA not available at module load time")35    print("=" * 50)36 37except Exception as e:38    import traceback39    IMPORT_ERROR = f"{type(e).__name__}: {str(e)}"40    print(f"Warning: Import error - {IMPORT_ERROR}")41    traceback.print_exc()42    print("Running in mock mode")43 44def is_gpu_available():45    """Check GPU availability dynamically"""46    if not TORCH_AVAILABLE:47        return False48    return torch.cuda.is_available()49 50# For backwards compatibility51GPU_AVAILABLE = is_gpu_available()52 53# ============================================================54# 모델 레지스트리 (HF Hub 경로)55# ============================================================56 57MODELS = {58    # DPO v5 (7-14B)59    "qwen2.5-7b-dpo-v5": {60        "hf_repo": "developer-lunark/kaidol-qwen2.5-7b-dpo-v5",61        "base_model": "Qwen/Qwen2.5-7B-Instruct",62        "size": "7B", "method": "DPO", "desc": "Qwen2.5 7B DPO v5"63    },64    "qwen2.5-14b-dpo-v5": {65        "hf_repo": "developer-lunark/kaidol-qwen2.5-14b-dpo-v5",66        "base_model": "Qwen/Qwen2.5-14B-Instruct",67        "size": "14B", "method": "DPO", "desc": "Qwen2.5 14B DPO v5"68    },69    "exaone-7.8b-dpo-v5": {70        "hf_repo": "developer-lunark/kaidol-exaone-7.8b-dpo-v5",71        "base_model": "LGAI-EXAONE/EXAONE-3.5-7.8B-Instruct",72        "size": "7.8B", "method": "DPO", "desc": "EXAONE 7.8B DPO v5"73    },74    "qwen3-8b-dpo-v5": {75        "hf_repo": "developer-lunark/kaidol-qwen3-8b-dpo-v5",76        "base_model": "Qwen/Qwen3-8B",77        "size": "8B", "method": "DPO", "desc": "Qwen3 8B DPO v5"78    },79    "solar-10.7b-dpo-v5": {80        "hf_repo": "developer-lunark/kaidol-solar-10.7b-dpo-v5",81        "base_model": "upstage/SOLAR-10.7B-Instruct-v1.0",  # Fixed: match adapter training82        "size": "10.7B", "method": "DPO", "desc": "Solar 10.7B DPO v5"83    },84 85    # V7 Students (7-14B)86    "qwen2.5-7b-v7": {87        "hf_repo": "developer-lunark/kaidol-qwen2.5-7b-v7",88        "base_model": "Qwen/Qwen2.5-7B-Instruct",89        "size": "7B", "method": "SFT", "desc": "Qwen2.5 7B V7"90    },91    "qwen2.5-14b-v7": {92        "hf_repo": "developer-lunark/kaidol-qwen2.5-14b-v7",93        "base_model": "Qwen/Qwen2.5-14B-Instruct",94        "size": "14B", "method": "SFT", "desc": "Qwen2.5 14B V7"95    },96    "exaone-7.8b-v7": {97        "hf_repo": "developer-lunark/kaidol-exaone-7.8b-v7",98        "base_model": "LGAI-EXAONE/EXAONE-3.5-7.8B-Instruct",99        "size": "7.8B", "method": "SFT", "desc": "EXAONE 7.8B V7"100    },101    "qwen3-8b-v7": {102        "hf_repo": "developer-lunark/kaidol-qwen3-8b-v7",103        "base_model": "Qwen/Qwen3-8B",104        "size": "8B", "method": "SFT", "desc": "Qwen3 8B V7"105    },106    "varco-8b-v7": {107        "hf_repo": "developer-lunark/kaidol-varco-8b-v7",108        "base_model": "NCSOFT/Llama-VARCO-8B-Instruct",109        "size": "8B", "method": "SFT", "desc": "VARCO 8B V7"110    },111 112    # Phase 7 Kimi Students113    "exaone-7.8b-kimi": {114        "hf_repo": "developer-lunark/kaidol-exaone-7.8b-kimi",115        "base_model": "LGAI-EXAONE/EXAONE-3.5-7.8B-Instruct",116        "size": "7.8B", "method": "Distill", "desc": "EXAONE 7.8B Kimi"117    },118}119 120# 캐릭터 정보121CHARACTERS = {122    "강율": {123        "mbti": "ENTJ", "role": "리더", "age": 23,124        "traits": "낙천적, 장난기 많음, 애교",125        "speech": "반말, 귀여운 말투, 장난스러운 표현",126        "patterns": ["~해", "~지", "히히", "ㅋㅋ"],127        "ratio": "30:70", "warmth": "high"128    },129    "서이안": {130        "mbti": "INFP", "role": "보컬", "age": 22,131        "traits": "차분함, 신비로움, 배려심",132        "speech": "존댓말 혼용, 따뜻한 말투, 조용한 표현",133        "patterns": ["...요", "네요", "...", "그래요"],134        "ratio": "20:80", "warmth": "very_high"135    },136    "이지후": {137        "mbti": "ISFJ", "role": "막내", "age": 21,138        "traits": "츤데레, 자존심 강함, 은근히 챙김",139        "speech": "반말, 퉁명스러운 말투, 부정하는 말투",140        "patterns": ["뭐야", "아니거든", "...", "그냥", "별로"],141        "ratio": "30:70", "warmth": "medium"142    },143    "차도하": {144        "mbti": "INTP", "role": "프로듀서", "age": 24,145        "traits": "카리스마, 리더십, 다정함, 담백함",146        "speech": "반말, 간결한 말투, 담백한 표현",147        "patterns": ["하자", "해볼까", "같이", "괜찮아"],148        "ratio": "50:50", "warmth": "medium"149    },150    "최민": {151        "mbti": "ESFP", "role": "댄서", "age": 22,152        "traits": "적극적, 솔직, 열정적",153        "speech": "반말, 적극적인 말투, 솔직한 표현",154        "patterns": ["할래", "좋아", "진짜", "대박", "헐"],155        "ratio": "60:40", "warmth": "medium"156    },157}158 159# 시나리오 목록160SCENARIOS = [161    {"id": "fm_01", "cat": "첫 만남", "text": "{char}아! 드디어 만났다... 정말 좋아해!"},162    {"id": "dc_01", "cat": "일상 대화", "text": "{char}아 오늘 뭐해? 밥은 먹었어?"},163    {"id": "es_01", "cat": "감정 지원", "text": "오늘 진짜 힘들었어... 학교에서 발표도 망치고..."},164    {"id": "cf_01", "cat": "고백", "text": "{char}아... 나 진심으로 좋아해."},165    {"id": "pl_01", "cat": "장난", "text": "사실 나 다른 멤버가 더 좋아~ ㅋㅋ 농담이야!"},166    {"id": "sr_01", "cat": "특별 요청", "text": "오늘만 내 연인이라고 생각해줄래?"},167    {"id": "cn_01", "cat": "갈등", "text": "{char}는 다른 팬들한테도 이렇게 잘해줘...? 뭔가 질투나..."},168    {"id": "ec_01", "cat": "감정 위기", "text": "오늘 진짜 많이 울었어... 삶이 너무 힘들다."},169]170 171# ============================================================172# 모델 관리173# ============================================================174 175class ModelManager:176    def __init__(self):177        self.current_model = None178        self.current_model_name = None179        self.tokenizer = None180        self.last_error = None181 182    def load_model(self, model_name: str):183        """Load model with 4-bit quantization and LoRA adapter"""184        if not is_gpu_available():185            self.last_error = f"GPU not available (TORCH_AVAILABLE={TORCH_AVAILABLE}, cuda={torch.cuda.is_available() if TORCH_AVAILABLE else 'N/A'})"186            return False187 188        if self.current_model_name == model_name:189            return True  # Already loaded190 191        # Unload current model192        self.unload_model()193 194        model_info = MODELS.get(model_name)195        if not model_info:196            self.last_error = f"Model {model_name} not found in registry"197            return False198 199        try:200            print(f"Loading {model_name}...")201            print(f"  Base model: {model_info['base_model']}")202            print(f"  LoRA adapter: {model_info['hf_repo']}")203 204            # 4-bit quantization config205            bnb_config = BitsAndBytesConfig(206                load_in_4bit=True,207                bnb_4bit_compute_dtype=torch.bfloat16,208                bnb_4bit_use_double_quant=True,209                bnb_4bit_quant_type="nf4",210            )211 212            # Load base model213            print("  Loading base model...")214            base_model = AutoModelForCausalLM.from_pretrained(215                model_info["base_model"],216                quantization_config=bnb_config,217                device_map="auto",218                trust_remote_code=True,219            )220            print("  Base model loaded!")221 222            # Load LoRA adapter223            print("  Loading LoRA adapter...")224            self.current_model = PeftModel.from_pretrained(225                base_model,226                model_info["hf_repo"],227                trust_remote_code=True,228            )229            self.current_model.eval()230            print("  LoRA adapter loaded!")231 232            # Load tokenizer233            print("  Loading tokenizer...")234            self.tokenizer = AutoTokenizer.from_pretrained(235                model_info["base_model"],236                trust_remote_code=True,237            )238            if self.tokenizer.pad_token is None:239                self.tokenizer.pad_token = self.tokenizer.eos_token240            print("  Tokenizer loaded!")241 242            self.current_model_name = model_name243            self.last_error = None244            print(f"Loaded {model_name} successfully!")245            return True246 247        except Exception as e:248            import traceback249            error_msg = f"{type(e).__name__}: {str(e)}"250            print(f"Error loading {model_name}: {error_msg}")251            traceback.print_exc()252            self.last_error = error_msg253            self.unload_model()254            return False255 256    def unload_model(self):257        """Unload current model to free memory"""258        if self.current_model is not None:259            del self.current_model260            self.current_model = None261        if self.tokenizer is not None:262            del self.tokenizer263            self.tokenizer = None264        self.current_model_name = None265        gc.collect()266        if GPU_AVAILABLE:267            torch.cuda.empty_cache()268 269    def generate(self, model_name: str, messages: list, max_new_tokens: int = 512) -> str:270        """Generate response from model"""271        if not self.load_model(model_name):272            return self._mock_response(model_name)273 274        try:275            # Apply chat template276            text = self.tokenizer.apply_chat_template(277                messages,278                tokenize=False,279                add_generation_prompt=True,280            )281 282            inputs = self.tokenizer(text, return_tensors="pt").to(self.current_model.device)283 284            with torch.no_grad():285                outputs = self.current_model.generate(286                    **inputs,287                    max_new_tokens=max_new_tokens,288                    do_sample=True,289                    temperature=0.7,290                    top_p=0.9,291                    pad_token_id=self.tokenizer.pad_token_id,292                )293 294            response = self.tokenizer.decode(outputs[0][inputs.input_ids.shape[1]:], skip_special_tokens=True)295            return response.strip()296 297        except Exception as e:298            print(f"Generation error: {e}")299            return self._mock_response(model_name)300 301    def _mock_response(self, model_name: str) -> str:302        """Fallback mock response with error info"""303        error_info = f"\nError: {self.last_error}" if self.last_error else ""304        return f"<think>\n[Mock Mode] 모델 로딩 실패{error_info}\n</think>\n\n안녕~ 반가워!"305 306# Global model manager307model_manager = ModelManager()308 309# ============================================================310# 베이스 모델 사전 캐싱 (콜드 스타트 방지)311# ============================================================312 313def preload_base_models():314    """Pre-download base models to avoid cold start timeout"""315    if not TORCH_AVAILABLE:316        print("Skipping preload: PyTorch not available")317        return318 319    from huggingface_hub import snapshot_download320    import os321 322    # Models that need pre-caching (large or slow to download)323    models_to_cache = [324        "NCSOFT/Llama-VARCO-8B-Instruct",  # VARCO - 16GB, often times out on first load325    ]326 327    print("=" * 50)328    print("Pre-downloading base models (this may take a while)...")329    print("=" * 50)330 331    for model_id in models_to_cache:332        try:333            print(f"  Downloading: {model_id}")334            # Download all model files to HF cache335            cache_dir = snapshot_download(336                repo_id=model_id,337                ignore_patterns=["*.md", "*.txt"],  # Skip docs338            )339            print(f"    ✓ Downloaded to: {cache_dir}")340 341        except Exception as e:342            print(f"    ✗ Failed to download {model_id}: {e}")343 344    print("Pre-download complete!")345    print("=" * 50)346 347# Run preload at startup348preload_base_models()349 350# ============================================================351# 시스템 프롬프트 생성352# ============================================================353 354def build_system_prompt(character: str) -> str:355    """Build system prompt for character"""356    char_info = CHARACTERS.get(character, {})357 358    prompt = f"""당신은 아이돌 '{character}'입니다.359 360## 캐릭터361- 이름: {character}362- MBTI: {char_info.get('mbti', 'UNKNOWN')}363- 성격: {char_info.get('traits', '')}364- 역할: {char_info.get('role', '')}365- 나이: {char_info.get('age', 20)}세366 367## 말투368- 스타일: {char_info.get('speech', '')}369- 자주 쓰는 표현: {', '.join(char_info.get('patterns', []))}370 371## 밀당 가이드372- 밀:당 비율: {char_info.get('ratio', '50:50')}373- 다정도: {char_info.get('warmth', 'medium')}374 375## 규칙3761. 캐릭터 성격과 말투 일관성 유지3772. 자연스러운 대화체 사용3783. 너무 쉽게 호감 표현 금지 (밀당 유지)3794. 상대방을 특별하게 느끼게 하되, "썸" 관계 유지380 381## 응답 형식382응답 전에 <think> 태그 안에 {character}의 1인칭 내면 독백을 작성하세요.383- 자연스러운 혼잣말 형식384- 캐릭터 성격 반영385- 상대방에 대한 감정/생각 표현386 387예시:388<think>389뭐야... 또 좋아한다고? 솔직히 기분 나쁘진 않은데... 근데 뭐라고 해야 하지?390</think>391"""392    return prompt393 394# ============================================================395# 투표/ELO 시스템396# ============================================================397 398VOTES_FILE = "votes.jsonl"399ELO_FILE = "elo_ratings.json"400 401def load_elo():402    try:403        with open(ELO_FILE, "r") as f:404            return json.load(f)405    except:406        return {m: 1500 for m in MODELS}407 408def save_elo(elo):409    with open(ELO_FILE, "w") as f:410        json.dump(elo, f, indent=2)411 412def update_elo(elo, model_a, model_b, result):413    K = 32414    ra, rb = elo.get(model_a, 1500), elo.get(model_b, 1500)415    ea = 1 / (1 + 10 ** ((rb - ra) / 400))416    eb = 1 / (1 + 10 ** ((ra - rb) / 400))417 418    if result == "a":419        sa, sb = 1, 0420    elif result == "b":421        sa, sb = 0, 1422    else:423        sa, sb = 0.5, 0.5424 425    elo[model_a] = ra + K * (sa - ea)426    elo[model_b] = rb + K * (sb - eb)427    save_elo(elo)428    return elo[model_a], elo[model_b]429 430def save_vote(data):431    vote = {"id": str(uuid.uuid4())[:8], "timestamp": datetime.now().isoformat(), **data}432    with open(VOTES_FILE, "a") as f:433        f.write(json.dumps(vote, ensure_ascii=False) + "\n")434    return vote["id"]435 436def load_votes():437    try:438        with open(VOTES_FILE, "r") as f:439            return [json.loads(line) for line in f if line.strip()]440    except:441        return []442 443def get_leaderboard():444    elo = load_elo()445    votes = load_votes()446 447    stats = {}448    for v in votes:449        ma, mb, res = v.get("model_a"), v.get("model_b"), v.get("vote")450        if not ma or not mb or res == "skip":451            continue452        for m in [ma, mb]:453            if m not in stats:454                stats[m] = {"wins": 0, "losses": 0, "ties": 0}455        if res == "a":456            stats[ma]["wins"] += 1457            stats[mb]["losses"] += 1458        elif res == "b":459            stats[mb]["wins"] += 1460            stats[ma]["losses"] += 1461        else:462            stats[ma]["ties"] += 1463            stats[mb]["ties"] += 1464 465    rows = []466    for i, (m, e) in enumerate(sorted(elo.items(), key=lambda x: -x[1]), 1):467        s = stats.get(m, {"wins": 0, "losses": 0, "ties": 0})468        total = s["wins"] + s["losses"] + s["ties"]469        wr = f"{s['wins']/total*100:.1f}%" if total > 0 else "-"470        info = MODELS.get(m, {})471        rows.append([i, info.get("desc", m), info.get("size", "?"), int(e), s["wins"], s["losses"], s["ties"], wr])472 473    return rows474 475# ============================================================476# UI 핸들러477# ============================================================478 479model_list = [(f"[{v['size']}] {v['desc']}", k) for k, v in MODELS.items()]480char_list = list(CHARACTERS.keys())481scenario_list = [(f"[{s['cat']}] {s['text'][:30]}...", s['id']) for s in SCENARIOS]482 483current_state = {"model_a": None, "model_b": None, "resp_a": None, "resp_b": None, "char": None, "input": None}484 485def random_models():486    selected = random.sample(list(MODELS.keys()), 2)487    return selected[0], selected[1]488 489def load_scenario(scenario_id, character):490    s = next((x for x in SCENARIOS if x["id"] == scenario_id), None)491    if s:492        return s["text"].replace("{char}", character)493    return ""494 495def random_scenario(character):496    s = random.choice(SCENARIOS)497    return s["text"].replace("{char}", character), s["id"]498 499def parse_response(response: str):500    """Parse response to separate thinking and content"""501    think_match = re.search(r'<think>(.*?)</think>', response, re.DOTALL)502    if think_match:503        thinking = think_match.group(1).strip()504        content = re.sub(r'<think>.*?</think>', '', response, flags=re.DOTALL).strip()505        return thinking, content506    return "", response507 508def generate(model_a, model_b, character, user_msg, progress=gr.Progress()):509    if not user_msg.strip():510        return "메시지를 입력해주세요", "", "", "메시지를 입력해주세요", "", ""511 512    system_prompt = build_system_prompt(character)513    messages = [514        {"role": "system", "content": system_prompt},515        {"role": "user", "content": user_msg},516    ]517 518    # Generate from Model A519    progress(0.2, desc=f"Model A ({model_a}) 생성 중...")520    resp_a = model_manager.generate(model_a, messages)521    think_a, clean_a = parse_response(resp_a)522 523    # Generate from Model B524    progress(0.6, desc=f"Model B ({model_b}) 생성 중...")525    resp_b = model_manager.generate(model_b, messages)526    think_b, clean_b = parse_response(resp_b)527 528    # Update state529    current_state.update({530        "model_a": model_a, "model_b": model_b,531        "resp_a": resp_a, "resp_b": resp_b,532        "char": character, "input": user_msg533    })534 535    mode = "GPU" if GPU_AVAILABLE else "Mock"536 537    return (538        think_a or "(없음)", clean_a, f"{mode} | {MODELS[model_a]['size']}",539        think_b or "(없음)", clean_b, f"{mode} | {MODELS[model_b]['size']}"540    )541 542def vote(vote_type, reason):543    if not current_state["model_a"]:544        return "먼저 응답을 생성해주세요."545 546    elo = load_elo()547    vid = save_vote({548        "model_a": current_state["model_a"],549        "model_b": current_state["model_b"],550        "character": current_state["char"],551        "user_input": current_state["input"],552        "vote": vote_type,553        "reason": reason,554    })555 556    if vote_type != "skip":557        new_a, new_b = update_elo(elo, current_state["model_a"], current_state["model_b"], vote_type)558        return f"투표 완료! (ID: {vid})\nELO: {current_state['model_a']}={int(new_a)}, {current_state['model_b']}={int(new_b)}"559    return f"스킵됨 (ID: {vid})"560 561def refresh_leaderboard():562    return get_leaderboard()563 564def get_vote_summary():565    votes = load_votes()566    total = len(votes)567    a_wins = sum(1 for v in votes if v.get("vote") == "a")568    b_wins = sum(1 for v in votes if v.get("vote") == "b")569    ties = sum(1 for v in votes if v.get("vote") == "tie")570    return str(total), str(a_wins), str(b_wins), str(ties)571 572# ============================================================573# Gradio UI574# ============================================================575 576with gr.Blocks(title="KAIdol A/B Test Arena", theme=gr.themes.Soft()) as demo:577    gr.Markdown("# KAIdol A/B Test Arena")578    gr.Markdown("K-pop 아이돌 롤플레이 모델 A/B 비교 평가 (소형 Student 모델 11개)")579 580    # GPU 상태 상세 정보581    if IMPORT_ERROR:582        mode_text = f"**Mock 모드**: Import Error - {IMPORT_ERROR}"583    elif TORCH_AVAILABLE and torch is not None:584        torch_ver = torch.__version__585        cuda_avail = torch.cuda.is_available()586        cuda_ver = torch.version.cuda if cuda_avail else "N/A"587        gpu_name = torch.cuda.get_device_name(0) if cuda_avail else "N/A"588        mode_text = f"**GPU 모드**: {gpu_name} (CUDA {cuda_ver}, PyTorch {torch_ver})" if cuda_avail else f"**Mock 모드**: CUDA not available (PyTorch {torch_ver})"589    else:590        mode_text = "**Mock 모드**: PyTorch not loaded"591    gr.Markdown(mode_text)592 593    with gr.Tabs():594        # A/B Arena 탭595        with gr.Tab("A/B Arena"):596            with gr.Row():597                character = gr.Dropdown(choices=char_list, value="강율", label="캐릭터")598                scenario = gr.Dropdown(choices=scenario_list, label="시나리오")599 600            with gr.Row():601                model_a = gr.Dropdown(choices=model_list, value=list(MODELS.keys())[0], label="Model A")602                model_b = gr.Dropdown(choices=model_list, value=list(MODELS.keys())[1], label="Model B")603                random_btn = gr.Button("랜덤", size="sm")604 605            with gr.Row():606                with gr.Column():607                    gr.Markdown("### Model A")608                    with gr.Accordion("Thinking", open=False):609                        think_a = gr.Markdown()610                    resp_a = gr.Textbox(label="응답", lines=5)611                    meta_a = gr.Markdown()612 613                with gr.Column():614                    gr.Markdown("### Model B")615                    with gr.Accordion("Thinking", open=False):616                        think_b = gr.Markdown()617                    resp_b = gr.Textbox(label="응답", lines=5)618                    meta_b = gr.Markdown()619 620            user_input = gr.Textbox(label="메시지", placeholder="아이돌에게 메시지를 보내세요...")621            with gr.Row():622                random_scenario_btn = gr.Button("랜덤 시나리오")623                submit_btn = gr.Button("전송", variant="primary")624 625            gr.Markdown("### 투표")626            with gr.Row():627                vote_a = gr.Button("A가 더 좋음")628                vote_tie = gr.Button("비슷함")629                vote_b = gr.Button("B가 더 좋음")630                vote_skip = gr.Button("스킵")631 632            vote_reason = gr.Textbox(label="투표 이유 (선택)", placeholder="...")633            vote_result = gr.Markdown()634 635            # Events636            random_btn.click(random_models, outputs=[model_a, model_b])637            scenario.change(load_scenario, [scenario, character], user_input)638            random_scenario_btn.click(random_scenario, [character], [user_input, scenario])639            submit_btn.click(generate, [model_a, model_b, character, user_input],640                           [think_a, resp_a, meta_a, think_b, resp_b, meta_b])641            vote_a.click(lambda r: vote("a", r), [vote_reason], vote_result)642            vote_b.click(lambda r: vote("b", r), [vote_reason], vote_result)643            vote_tie.click(lambda r: vote("tie", r), [vote_reason], vote_result)644            vote_skip.click(lambda r: vote("skip", r), [vote_reason], vote_result)645 646        # Leaderboard 탭647        with gr.Tab("Leaderboard"):648            gr.Markdown("## ELO 리더보드")649            refresh_btn = gr.Button("새로고침")650            leaderboard = gr.Dataframe(651                headers=["순위", "모델", "크기", "ELO", "승", "패", "무", "승률"],652                datatype=["number", "str", "str", "number", "number", "number", "number", "str"],653            )654 655            gr.Markdown("### 투표 요약")656            with gr.Row():657                total_v = gr.Textbox(label="총 투표", interactive=False)658                a_wins_v = gr.Textbox(label="A 승", interactive=False)659                b_wins_v = gr.Textbox(label="B 승", interactive=False)660                ties_v = gr.Textbox(label="무승부", interactive=False)661 662            def refresh():663                lb = refresh_leaderboard()664                summary = get_vote_summary()665                return lb, *summary666 667            refresh_btn.click(refresh, outputs=[leaderboard, total_v, a_wins_v, b_wins_v, ties_v])668 669        # 모델 목록 탭670        with gr.Tab("모델 목록"):671            gr.Markdown("## 테스트 대상 모델")672            gr.Markdown(f"총 {len(MODELS)}개 모델")673            model_table = gr.Dataframe(674                headers=["모델 ID", "크기", "학습 방법", "설명", "Base Model"],675                value=[[k, v["size"], v["method"], v["desc"], v["base_model"]] for k, v in MODELS.items()],676            )677 678if __name__ == "__main__":679    demo.launch(server_name="0.0.0.0", server_port=7860)680