import html import os import re from pathlib import Path import gradio as gr import torch from peft import LoraConfig, PeftModel, TaskType from transformers import AutoModelForSequenceClassification, AutoTokenizer BASE_MODEL = "monologg/koelectra-small-v3-discriminator" LORA_DIR = Path(__file__).parent / "lora_climate_misinfo" MAX_LENGTH = 512 CONTACT_EMAIL = os.getenv("CONTACT_EMAIL", "").strip() DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu") # 학습 데이터의 라벨 정의: 0=정상, 1=위험(가짜뉴스) LABELS = {0: "정상", 1: "가짜뉴스 위험"} LIMITATION_TEXT = ( "이 모델은 AI 신약개발 관련 뉴스의 진위 판별을 돕는 보조 도구이며, 완벽한 사실검증기가 아닙니다. " "Model Card의 스트레스 테스트에서는 공신력 있는 기관명을 도용한 문장, 의약품과 무관한 공신력 기관명을 " "가져온 문장, 거짓 정보를 구체적 수치로 작성한 문장에서 오판 가능성이 확인되었습니다. " "특히 우회적·비꼬기 표현도 취약할 수 있으므로 최종 판단은 원문 출처와 논문·기관 자료를 함께 확인하세요." ) CUSTOM_CSS = r""" .gradio-container {max-width: 980px !important; margin: 0 auto !important;} #hero {text-align:center; margin-bottom: 8px;} .warning-box {border: 1px solid #e5b94f; background:#fff8df; border-radius:14px; padding:14px 16px; margin:8px 0 18px 0;} .result-card {border:2px solid #222; border-radius:16px; padding:16px; background:white; min-height:132px;} .result-head {font-size:14px; color:#555; margin-bottom:8px;} .result-main {display:flex; align-items:center; gap:10px; font-size:24px; font-weight:700;} .dot {width:18px; height:18px; border-radius:50%; display:inline-block; flex:0 0 auto;} .dot-risk {background:#c95b4b;} .dot-safe {background:#4e9f6a;} .score-line {margin-top:10px; font-size:16px;} .xai-box {border:2px solid #222; border-radius:20px; padding:14px; background:#fff;} .xai-title {display:inline-block; border:2px solid #222; border-radius:20px; padding:2px 12px; font-weight:700; margin-bottom:12px;} .token-wrap {display:flex; flex-wrap:wrap; gap:8px;} .token-chip {border:1px solid #777; border-radius:12px; padding:6px 9px; background:#f7f7f7;} .token-chip strong {font-weight:700;} .small-note {font-size:13px; color:#666; margin-top:10px;} .disclaimer {border:1px solid #999; border-radius:14px; padding:12px 14px; background:#f7f7f7; font-size:14px;} .error-card {border:2px solid #a33; border-radius:14px; padding:14px; background:#fff3f3; color:#7b1f1f;} """ def preprocess_text(text: str) -> str: """흐름도의 '텍스트 전처리' 단계. 의미를 훼손하지 않는 범위에서 공백만 정리합니다.""" text = (text or "").strip() text = re.sub(r"\s+", " ", text) return text def build_peft_config() -> LoraConfig: """PEFT 0.9.0과 호환되는 LoRA 설정을 코드에서 명시적으로 생성합니다. 업로드된 어댑터는 PEFT 0.20.0에서 저장되어 새 필드가 포함되어 있었기 때문에, 구버전에서 'unexpected keyword'가 나지 않도록 필요한 설정만 사용합니다. """ return LoraConfig( r=8, lora_alpha=16, target_modules=["query", "value"], lora_dropout=0.1, bias="none", task_type=TaskType.SEQ_CLS, modules_to_save=["classifier", "score"], inference_mode=True, use_rslora=False, use_dora=False, ) def load_model_bundle(): tokenizer = AutoTokenizer.from_pretrained(BASE_MODEL, use_fast=True) base_model = AutoModelForSequenceClassification.from_pretrained( BASE_MODEL, num_labels=2, ) peft_config = build_peft_config() model = PeftModel.from_pretrained( base_model, str(LORA_DIR), config=peft_config, is_trainable=False, ) model.to(DEVICE) model.eval() return tokenizer, model TOKENIZER = None MODEL = None MODEL_LOAD_ERROR = None try: TOKENIZER, MODEL = load_model_bundle() except Exception as exc: # Space 자체가 죽지 않고 UI에서 원인을 확인할 수 있게 함 MODEL_LOAD_ERROR = f"{type(exc).__name__}: {exc}" def merge_wordpieces(tokens, scores): """WordPiece 토큰을 사람이 읽기 쉬운 단위로 묶어 상위 항목을 반환합니다.""" merged = [] for token, score in zip(tokens, scores): if token in {"[CLS]", "[SEP]", "[PAD]"}: continue if token.startswith("##") and merged: prev_token, prev_score = merged[-1] merged[-1] = (prev_token + token[2:], max(prev_score, float(score))) else: merged.append((token, float(score))) # 같은 표면형이 반복되면 가장 높은 점수만 유지 best = {} for token, score in merged: token = token.strip() if not token or token in {"[UNK]"}: continue best[token] = max(score, best.get(token, -1.0)) return sorted(best.items(), key=lambda x: x[1], reverse=True) def extract_attention_guide(encoded, attentions, top_k=6): """마지막 층의 [CLS]→토큰 평균 attention을 간단한 참고 신호로 사용합니다. Attention은 인과적 설명이 아니므로 UI에도 그 한계를 명시합니다. """ if not attentions: return [] last = attentions[-1][0] # [heads, seq, seq] cls_to_tokens = last[:, 0, :].mean(dim=0).detach().cpu().tolist() ids = encoded["input_ids"][0].detach().cpu().tolist() tokens = TOKENIZER.convert_ids_to_tokens(ids) merged = merge_wordpieces(tokens, cls_to_tokens) return merged[:top_k] def result_html(pred_label: int, risk_prob: float, confidence: float): is_risk = pred_label == 1 dot_class = "dot-risk" if is_risk else "dot-safe" title = "가짜뉴스 위험" if is_risk else "정상 가능성 높음" return f"""