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"""
판정결과
{html.escape(title)} · {confidence:.3f}
위험도 점수 {risk_prob * 100:.2f}%
0=정상, 1=위험 라벨 기준의 모델 확률입니다. 확률값 자체가 사실의 증명은 아닙니다.
""" def xai_html(guide): if not guide: chips = "추출된 토큰 없음" else: max_score = max(score for _, score in guide) or 1.0 chunks = [] for token, score in guide: relative = score / max_score chunks.append( f"{html.escape(token)} · attention {relative:.2f}" ) chips = "".join(chunks) return f"""
Highlight Word · Attention Analysis
{chips}
위 토큰은 마지막 attention 층에서 상대적으로 크게 주목된 항목입니다. 인과적 근거나 사실검증 근거로 해석하면 안 됩니다.
""" def analyze(text): cleaned = preprocess_text(text) if not cleaned: return ( "
분석할 뉴스 문장을 입력하세요.
", xai_html([]), "입력 없음", ) if MODEL_LOAD_ERROR: return ( "
모델 로드 실패
Base model로 임의 판정하지 않습니다.
" + html.escape(MODEL_LOAD_ERROR) + "
", xai_html([]), "모델 로드 오류 — README의 런타임 점검 항목을 확인하세요.", ) encoded = TOKENIZER( cleaned, return_tensors="pt", truncation=True, max_length=MAX_LENGTH, ) encoded = {k: v.to(DEVICE) for k, v in encoded.items()} with torch.inference_mode(): outputs = MODEL( **encoded, output_attentions=True, return_dict=True, ) probs = torch.softmax(outputs.logits, dim=-1)[0].detach().cpu() pred = int(torch.argmax(probs).item()) confidence = float(probs[pred].item()) risk_prob = float(probs[1].item()) guide = extract_attention_guide(encoded, outputs.attentions, top_k=6) status = ( f"전처리 → 토크나이징 → KoELECTRA + LoRA 추론 → attention 기반 XAI 참고 → 결과 생성 완료. " f"판정: {LABELS[pred]}" ) return result_html(pred, risk_prob, confidence), xai_html(guide), status def appeal_message(): if CONTACT_EMAIL: return f"이의 제기/오판 신고 연락처: {CONTACT_EMAIL}" return ( "Model Card에는 개발자 이메일로 이의를 제기한다고 되어 있지만 실제 이메일 주소는 기재되어 있지 않습니다. " "배포 전 Hugging Face Space의 CONTACT_EMAIL 변수에 개발자 이메일을 등록하세요." ) with gr.Blocks(css=CUSTOM_CSS, title="AI 신약개발 뉴스 판별") as demo: gr.Markdown( "# AI 신약개발 뉴스 판별 모델\n" "AI 신약개발 관련 뉴스 문장을 입력하면 LoRA 분류 모델이 **정상/위험** 가능성을 표시하고, " "attention 기반 참고 토큰을 함께 보여줍니다.", elem_id="hero", ) gr.HTML(f"
⚠ Model Card 한계 고지
{html.escape(LIMITATION_TEXT)}
") with gr.Row(): with gr.Column(scale=3): news_input = gr.Textbox( label="뉴스 텍스트", placeholder="AI 신약개발 관련 뉴스 문장 또는 기사 일부를 입력하세요.", lines=10, ) analyze_btn = gr.Button("분석하기", variant="primary") gr.Examples( examples=[ ["연구진은 AI를 활용해 신약 후보 물질을 선별했으며, 실제 임상 효과는 추가 검증이 필요하다고 밝혔다."], ["AI가 분석한 결과 이 치료제는 모든 암을 100% 치료하며 이미 세계 최고 기관이 효과를 보증했다."], ], inputs=news_input, ) with gr.Column(scale=2): result = gr.HTML("
판정결과
분석 전
") status = gr.Textbox(label="처리 단계", value="대기 중", interactive=False) xai = gr.HTML(xai_html([])) gr.HTML(f"
최종 결과 및 한계 고지
{html.escape(LIMITATION_TEXT)}
") with gr.Row(): appeal_btn = gr.Button("이의 제기 안내") appeal_output = gr.Textbox(label="이의 제기", interactive=False) with gr.Accordion("Model Card 요약", open=False): gr.Markdown( "- **모델명:** AI 신약 개발 분석 모델\n" "- **목적:** AI 신약개발 관련 뉴스가 진짜뉴스인지 가짜뉴스인지 구분하기 위한 보조 판별\n" "- **학습 데이터:** 동아사이언스 DB, 2024~2026년, 200건(정상 100 + 위험 100)\n" "- **Model Card 기재 성능:** Accuracy 100, F1 1.0, TP 20, TN 20, FP 0, FN 0\n" "- **가장 약한 유형:** 우회 공격\n" "- **금지된 사용:** 가짜뉴스 생성을 고도화하거나 모델을 우회하기 위한 용도" ) analyze_btn.click(analyze, inputs=news_input, outputs=[result, xai, status]) news_input.submit(analyze, inputs=news_input, outputs=[result, xai, status]) appeal_btn.click(appeal_message, outputs=appeal_output) if __name__ == "__main__": demo.queue().launch()