File size: 11,924 Bytes
88f6968
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
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"""
    <div class='result-card'>
      <div class='result-head'>판정결과</div>
      <div class='result-main'><span class='dot {dot_class}'></span>{html.escape(title)} · {confidence:.3f}</div>
      <div class='score-line'><b>위험도 점수</b> {risk_prob * 100:.2f}%</div>
      <div class='small-note'>0=정상, 1=위험 라벨 기준의 모델 확률입니다. 확률값 자체가 사실의 증명은 아닙니다.</div>
    </div>
    """


def xai_html(guide):
    if not guide:
        chips = "<span class='token-chip'>추출된 토큰 없음</span>"
    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"<span class='token-chip'><strong>{html.escape(token)}</strong> · attention {relative:.2f}</span>"
            )
        chips = "".join(chunks)
    return f"""
    <div class='xai-box'>
      <div class='xai-title'>Highlight Word · Attention Analysis</div>
      <div class='token-wrap'>{chips}</div>
      <div class='small-note'>위 토큰은 마지막 attention 층에서 상대적으로 크게 주목된 항목입니다. 인과적 근거나 사실검증 근거로 해석하면 안 됩니다.</div>
    </div>
    """


def analyze(text):
    cleaned = preprocess_text(text)
    if not cleaned:
        return (
            "<div class='error-card'>분석할 뉴스 문장을 입력하세요.</div>",
            xai_html([]),
            "입력 없음",
        )
    if MODEL_LOAD_ERROR:
        return (
            "<div class='error-card'><b>모델 로드 실패</b><br>Base model로 임의 판정하지 않습니다.<br>"
            + html.escape(MODEL_LOAD_ERROR)
            + "</div>",
            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"<div class='warning-box'><b>⚠ Model Card 한계 고지</b><br>{html.escape(LIMITATION_TEXT)}</div>")

    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("<div class='result-card'><div class='result-head'>판정결과</div><div>분석 전</div></div>")
            status = gr.Textbox(label="처리 단계", value="대기 중", interactive=False)

    xai = gr.HTML(xai_html([]))

    gr.HTML(f"<div class='disclaimer'><b>최종 결과 및 한계 고지</b><br>{html.escape(LIMITATION_TEXT)}</div>")
    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()