# -*- coding: utf-8 -*- """제목주의지수 (4).ipynb Automatically generated by Colab. Original file is located at https://colab.research.google.com/drive/1v2tMK6_NdEthlQJAU-Hipwkprq70y2jt """ import os import re import sys import numpy as np import pandas as pd import torch from tqdm import tqdm from transformers import PreTrainedTokenizerFast, BartForConditionalGeneration from sentence_transformers import SentenceTransformer, util import re, math, json, numpy as np, pandas as pd, torch from typing import List, Dict, Tuple, Any from collections import Counter import argparse DEVICE = ("cuda" if torch.cuda.is_available() else "mps" if getattr(torch.backends, "mps", None) and torch.backends.mps.is_available() else "cpu") SIM_DEVICE = "cpu" if DEVICE == "mps" else DEVICE print(f"[INFO] Gen Device: {DEVICE} | Sim Device: {SIM_DEVICE}") exag = {'가득': 2, '가세': 2, '가속': 2, '강력': 1, '강하다': 1, '거품': 3, '격돌': 1, '격앙': 1, '격차': 1, '경악': 1, '고비': 2, '고삐': 1, '고조': 2, '고지': 3, '고통': 3, '공세': 1, '공포': 1, '과장': 1, '광폭': 2, '광풍': 3, '괴물': 2, '구원투수': 3, '굴욕': 3, '극적': 2, '극찬': 2, '글쎄': 2, '급감': 2, '급등': 2, '급발진': 2, '급속': 2, '기승': 1, '기적': 2, '깜짝': 1, '껑충': 2, '꼴찌': 3, '꼼수': 1, '꽁꽁': 2, '꽂히다': 1, '꿀꺽': 2, '꿈틀': 1, '끔찍': 1, '난리': 2, '난항': 1, '날다': 1, '날벼락': 3, '냉각': 2, '넘치다': 1, '논란': 1, '놀라다': 1, '눈덩이': 2, '눈물': 2, '당장': 2, '대규모': 2, '대란': 3, '대박': 3, '대반전': 2, '대폭': 2, '대환영': 2, '덕분': 1, '돌파구': 2, '돌풍': 4, '뒷걸음질': 2, '뒷북': 3, '든든한': 2, '들썩': 1, '떡락': 3, '떡상': 3, '뚝딱': 2, '뚝뚝': 2, '뜨겁다': 2, '러브콜': 3, '레전드': 4, '막차': 3, '만능': 1, '매우': 2, '맵다': 2, '멘붕': 2, '몸살': 3, '무더기': 2, '급물살': 1, '뭇매': 2, '뭉칫돈': 2, '밉다': 3, '바람': 2, '박살': 3, '반전': 1, '반짝': 2, '발칵': 1, '방긋': 2, '방점': 2, '배신': 3, '벌써': 1, '벼랑': 3, '봇물': 2, '부담': 1, '분노': 3, '분수령': 2, '불가피': 1, '불과': 2, '불금': 2, '불기둥': 2, '불꽃': 2, '불똥': 2, '불씨': 1, '불안하다': 2, '불투명': 1, '불확실': 1, '붕괴': 1, '비명': 3, '뻥튀기': 2, '사상': 2, '상급': 3, '상승': 1, '선방': 1, '설상가상': 3, '성큼': 2, '소름': 1, '속출': 2, '손절': 2, '솔솔': 1, '쇼크': 3, '수백': 2, '수상한': 2, '수혈': 2, '순항': 1, '승기': 3, '시름': 2, '신기록': 2, '실망': 1, '심각': 1, '싹쓸이': 3, '쏟아지다': 1, '쓰리다': 2, '아비규환': 2, '악몽': 3, '악재': 1, '안간힘': 2, '안갯속': 2, '안도': 1, '알짜': 1, '압도적': 3, '압승': 3, '야심작': 3, '얼어붙다': 2, '역대': 2, '역대 최고': 2, '역대 최다': 2, '역대 최소': 2, '역대 최저 ': 2, '역대최고': 2, '역대최다': 2, '역대최소': 2, '역대최저 ': 2, '열풍': 3, '영광': 2, '영웅': 3, '오락가락': 2, '온기': 2, '와르르': 3, '와우': 3, '완패': 3, '외면': 2, '외환위기 이후': 2, '외환 위기 이후': 2, '요동치다': 2, '우뚝': 2, '우려': 1, '울다': 2, '위기급': 4, '위기': 3, '위축': 1, '위태': 2, '위협': 1, '유력': 2, '육박': 2, '의혹': 1, '잔치': 3, '잘나가다': 2, '재난급': 4, '저격': 3, '전격': 1, '전설': 3, '절대': 2, '절벽': 4, '족쇄': 2, '주의보': 2, '줄줄이': 2, '중증': 3, '증발': 2, '직격탄': 2, '진통': 2, '질타': 3, '쪽박': 2, '참담': 2, '척척': 2, '초대형': 2, '초비상': 2, '초유': 2, '초토화': 2, '촉각': 2, '최대': 2, '최상': 2, '최선': 2, '최악': 2, '최애': 2, '최저': 2, '최적': 1, '최초': 2, '최후': 2, '추락': 4, '출혈': 2, '충격': 1, '코앞': 3, '털썩': 2, '톡톡': 2, '투톱': 3, '특급': 4, '파격': 1, '편법': 1, '폭락': 3, '폭발': 2, '폭주': 2, '폭증': 2, '폭탄': 2, '폭풍': 2, '하락': 1, '한숨': 2, '함박': 3, '함정': 2, '허리띠': 1, '헌정 사상': 2, '헌정사상': 2, '혁명': 2, '호소': 1, '호평일색': 2, '호평 일색': 2, '호황': 3, '혼돈': 2, '홈런': 2, '확대': 1, '활기': 2, '활발': 1, '활짝': 2, '활활': 2, '후끈': 2, '훨훨': 2, '휩쓸다': 2, '흔들다': 2, 'imf 이후': 2, '역대급': 4, '무궁무진': 2, '1보': 1, '2보': 1, '3보': 1, '단독': 1, '속보': 1, '패닉': 3, '불패': 3, '제동': 2, '조짐': 1, '초긴장': 2, '급제동': 2, '뚝': 2, '복병': 2, '아우성': 3, '좌불안석': 3, '빈손': 2, '대세': 3, '생트집': 3, '주춤': 2, '끄덕': 2, '맞불': 2, '장벽': 2, '썰렁': 2, '먹구름': 3, '부메랑': 2, '롤러코스터': 2, '발목': 2, '반토막': 2, '휘청': 2, '곤두박질': 3, '울상': 2, '위풍당당': 3, '싸늘': 2, '주저': 1, '우수수': 2, '골머리': 2, '공화국': 3, '고공행진': 4} econ_list = ['(? str: if not isinstance(text, str): return "" text = RE_BULLETS.sub("", text) text = RE_GUIDE.sub("", text) text = RE_ROLES.sub("", text) text = RE_EMAIL.sub("", text) text = RE_EXTRA.sub("", text) text = RE_MULTINL.sub("\n", text).strip() text = RE_LSTRIP.sub("", text) return text def sentence_split(text: str): if not isinstance(text, str): text = "" if text is None else str(text) text = text.replace("\n", ".") text = re.sub(r"\.{2,}", ".", text) return [s.strip() for s in text.split("다.") if s.strip()] def top5_title_body_sim(title: str, body_text: str, sbert) -> float: sents = sentence_split(body_text) if not sents: return float("nan") title_emb = sbert.encode(title, convert_to_tensor=True, normalize_embeddings=True) sent_embs = sbert.encode(sents, convert_to_tensor=True, normalize_embeddings=True) sims = util.pytorch_cos_sim(title_emb, sent_embs)[0].detach().cpu().numpy().tolist() sims.sort(reverse=True) return float(np.mean(sims[:5])) if sims else float("nan") # 필요한 모델 불러오기 _tok = _bart = _sbert = None def load_models(): global _tok, _bart, _sbert if _tok is None or _bart is None: _tok = PreTrainedTokenizerFast.from_pretrained("digit82/kobart-summarization") if _tok.pad_token is None: _tok.pad_token = _tok.eos_token _tok.model_max_length = 1024 _bart = BartForConditionalGeneration.from_pretrained("digit82/kobart-summarization") _bart.eval().to(DEVICE) if DEVICE == "cuda": try: _bart.half() except Exception: pass if _sbert is None: _sbert = SentenceTransformer("snunlp/KR-SBERT-V40K-klueNLI-augSTS", device=SIM_DEVICE) return _tok, _bart, _sbert @torch.inference_mode() def summarize(tok, model, text: str, max_new_tokens: int = 160) -> str: if not text: return "" enc = tok(text, return_tensors="pt", truncation=True, max_length=1024, padding=False) out = model.generate( input_ids=enc["input_ids"].to(DEVICE), attention_mask=enc["attention_mask"].to(DEVICE), max_new_tokens=max_new_tokens, num_beams=4, no_repeat_ngram_size=3, length_penalty=1.0, early_stopping=True, use_cache=True ) return tok.decode(out[0], skip_special_tokens=True) # 과장 표현 정규화 예외 설정 NORM_RULES = [ (r'외환\s*위기\s*이후', '외환위기이후'), (r'IMF\s*이후', 'IMF이후'), (r'imf\s*이후', 'imf이후'), (r'IMF\s*급', 'IMF급'), (r'imf\s*급', 'imf급'), (r'호평\s*일색', '호평일색'), (r'헌정\s*사상', '헌정사상'), (r'역대\s*최고', '역대최고'), (r'역대\s*최다', '역대최다'), (r'역대\s*최소', '역대최소'), (r'역대\s*최저', '역대최저'), ] USER_TERMS = [ '대반전', '외환위기이후', '위기급', '재난급', '급물살', 'IMF이후', 'imf이후', 'IMF급', 'imf급', '역대최고', '역대최다', '역대최소', '역대최저', '역대급', '떡상', '떡락', '호평일색', '헌정사상', ] def normalize_expressions(text: str) -> str: t = text if isinstance(text, str) else "" for pat, rep in NORM_RULES: t = re.sub(pat, rep, t) return t _score_map: Dict[str, int] = None _unique_expr: List[str] = None _lex_pats: List[re.Pattern] = None _kiwi = None def _load_label_score_map_from_dict(exag_dict: Dict[str, int]) -> Tuple[Dict[str,int], List[str]]: """ exag 딕셔너리에서 점수 맵/표현 리스트 생성 """ score_map: Dict[str, int] = {} for k, v in (exag_dict or {}).items(): key = re.sub(r"\s+", "", str(k)).strip() try: val = int(v) except Exception: val = 0 if key: if key in score_map: score_map[key] = max(score_map[key], val) else: score_map[key] = val unique_expr = sorted(score_map.keys()) return score_map, unique_expr def _compile_patterns_from_list(regex_list: List[str]) -> List[re.Pattern]: """ econ_list 문자열 배열에서 정규식 패턴 컴파일 """ pats: List[re.Pattern] = [] for p in (regex_list or []): if not isinstance(p, str): continue pat = p.strip() if not pat: continue try: pats.append(re.compile(pat, re.I)) except re.error: # 잘못된 패턴은 무시 pass return pats def _build_kiwi(unique_expr: List[str]): """ Kiwi > Okt > regex 순으로 형태소/토큰 추출기 준비 """ # 1) kiwipiepy try: from kiwipiepy import Kiwi kiwi = Kiwi() for w in USER_TERMS: kiwi.add_user_word(w, 'NNG', 10) for w in unique_expr: if isinstance(w, str) and len(w) >= 2: kiwi.add_user_word(w, 'NNG', 9) return kiwi, "kiwi" except Exception: pass # 2) konlpy Okt try: from konlpy.tag import Okt _okt = Okt() def _okt_extract(text: str): norm = normalize_expressions(text) # 명사/동사만 return [w for w, t in _okt.pos(norm, norm=True, stem=True) if t in ("Noun","Verb")] return _okt_extract, "okt" except Exception: # 3) 정규식 토큰 나누기 def _regex_extract(text: str): norm = normalize_expressions(text) return re.findall(r"[가-힣A-Za-z0-9]+", norm) return _regex_extract, "regex" def _ensure_resources(): global _score_map, _unique_expr, _lex_pats, _kiwi try: exag_dict = exag except NameError: raise RuntimeError("exag 딕셔너리가 정의되어 있지 않습니다. exag = {'표현': 점수, ...} 형태로 먼저 정의하세요.") if _score_map is None or _unique_expr is None: _score_map, _unique_expr = _load_label_score_map_from_dict(exag_dict) if _lex_pats is None: try: econ = econ_list except NameError: econ = [] _lex_pats = _compile_patterns_from_list(econ) if _kiwi is None: _kiwi, _ = _build_kiwi(_unique_expr) # 형태소 추출(명사+동사) def extract_noun_verb_kiwi(text: str) -> List[str]: _ensure_resources() norm = normalize_expressions(text) try: from kiwipiepy import Kiwi if isinstance(_kiwi, Kiwi): toks = [] for tok in _kiwi.tokenize(norm): tag = tok.tag if tag.startswith("NN"): toks.append(tok.form) elif tag == "VV": toks.append(tok.lemma if tok.lemma else tok.form) return toks except Exception: pass return _kiwi(norm) # 과장 라벨 점수 계산 및 가중치 산출 def _calc_raw_and_count(tokens: List[str]) -> Tuple[int, int]: _ensure_resources() if not isinstance(tokens, (list, tuple)): return 0, 0 toks = [str(t).strip() for t in tokens if (t is not None) and str(t).strip() != ""] joined = "".join(toks) total_count, total_score = 0, 0 for expr, sc in _score_map.items(): c = joined.count(expr) # non-overlapping if c: total_count += c total_score += c * int(sc) return int(total_score), int(total_count) def _bin_label(total_raw: int) -> int: # (-inf,0] -> 0, [1,2] -> 1, [3,4] -> 2, [5, inf) -> 3 if total_raw <= 0: return 0 if 1 <= total_raw <= 2: return 1 if 3 <= total_raw <= 4: return 2 return 3 def _weight_by_count(n: int) -> float: if n == 1: return 1.0 if n == 2: return 1.3 if n == 3: return 1.5 if n >= 4: return 1.7 return 0.0 def _has_keyword_and_matches(text: str) -> Tuple[bool, List[str]]: _ensure_resources() t = text or "" seen, out = set(), [] has_any = False for pat in _lex_pats: m = pat.search(t) if m: has_any = True s = m.group(0) if s not in seen: seen.add(s) out.append(s) return has_any, out import math def title_attention_index(score: float) -> str: if score is None or (isinstance(score, float) and math.isnan(score)): return "점수 없음. \n다시 제목과 본문을 입력해주세요" # 구간: [0,0.95) 양호, [0.95,2.25) 관심, [2.25,3.70) 주의, [3.70,5) 매우 주의 if score < 0.95: return "양호✅ \n본문이 제목에 잘 반영되어 있는 양호한 기사로 그대로 읽기를 권장합니다." if score < 2.25: return "관심📌 \n경미한 과장 또는 제목-본문 간의 불일치가 있으나 경미한 수준입니다. \n제목 뿐만 아니라 본문 확인을 권장합니다." if score < 3.70: return "주의⚠️ \n제목에 과장표현의 빈도가 높거나 제목-본문 간의 불일치가 높아 본문을 꼼꼼히 살펴보길 권장합니다." return "매우 주의🚨 \n제목 내 심한 과장표현은 물론, 제목-본문 간의 불일치가 우려됩니다. \n보다 유의하여 기사의 본문을 살펴보시길 권장합니다." # 메인 파이프라인 def run_once(title: str, body: str, short_pass_len: int = 50, max_new_tokens: int = 160): _ensure_resources() # 모델 tok, bart, sbert = load_models() # 본문 전처리 body_clean = preprocess_text(body) # 요약 if len(body_clean) < short_pass_len: summ = body_clean else: try: @torch.inference_mode() def _summarize(tok, model, text, max_new_tokens=160): enc = tok(text, return_tensors="pt", truncation=True, max_length=1024, padding=False) out = model.generate( input_ids=enc["input_ids"].to(DEVICE), attention_mask=enc["attention_mask"].to(DEVICE), max_new_tokens=max_new_tokens, num_beams=4, no_repeat_ngram_size=3, length_penalty=1.0, early_stopping=True, use_cache=True ) return tok.decode(out[0], skip_special_tokens=True) summ = _summarize(tok, bart, body_clean, max_new_tokens=max_new_tokens) except Exception as e: print(f"[WARN] summarization failed: {e}") summ = "" # 유사도 try: if summ: tvec = sbert.encode(title, convert_to_tensor=True, normalize_embeddings=True) svec = sbert.encode(summ, convert_to_tensor=True, normalize_embeddings=True) sim_sy = float(util.pytorch_cos_sim(tvec, svec).item()) else: sim_sy = float("nan") except Exception as e: print(f"[WARN] title-summary sim failed: {e}") sim_sy = float("nan") try: sim_b5 = top5_title_body_sim(title, body_clean, sbert) except Exception as e: print(f"[WARN] title-body top5 sim failed: {e}") sim_b5 = float("nan") # 제목 형태소(명사/동사) try: title_nv = extract_noun_verb_kiwi(title) except Exception as e: print(f"[WARN] kiwi extract failed: {e}") title_nv = re.findall(r"[가-힣A-Za-z0-9]+", normalize_expressions(title or "")) # 라벨 원점수/등장횟수 → 라벨점수 → 가중치 최종점수 raw_score, cnt = _calc_raw_and_count(title_nv) label_score = _bin_label(raw_score) # 0/1/2/3 weight = _weight_by_count(cnt) # 1.0/1.3/1.5/1.7 label_final = float(label_score) * float(weight) # df['최종점수'] # 본문 키워드 여부/매칭 → 1.15배 has_kw, matches = _has_keyword_and_matches(body_clean) exag_score = label_final * (1.15 if has_kw else 1.0) # df["과장점수"] # 불일치도 & log10 summary_mismatch = (1 - sim_sy) if not np.isnan(sim_sy) else np.nan body_mismatch = (1 - sim_b5) if not np.isnan(sim_b5) else np.nan exag_log10 = float(np.log10(exag_score + 1.0)) # 최종 기사 점수 if not (np.isnan(summary_mismatch) or np.isnan(body_mismatch)): final_article_score = round((exag_log10*0.5 + summary_mismatch*0.25 + body_mismatch*0.25) * 5, 2) else: final_article_score = np.nan return { "요약": summ, "요약유사도": sim_sy, "본문 일치도(Top5 평균)": sim_b5, "title_nv": title_nv, #형태소 분석된 제목 리스트 "원점수": raw_score, #과장 표현 원점수 "등장횟수": cnt, "라벨점수": int(label_score), "가중치": float(weight), "라벨최종점수": float(label_final), "has_keyword": bool(has_kw), #가중 키워드 본문 포함 여부 "matches": matches, # 가중 키워드로 선정된 키워드 리스트 "과장점수": float(exag_score), "과장점수_log10": exag_log10, #과장 최종 점수 "요약 불일치도": summary_mismatch, "본문 불일치도": body_mismatch, "최종 기사 점수": final_article_score } def run_cli(): print("제목을 입력하세요:") title = input().strip() print("본문을 입력하세요:") body = input().strip() r = run_once(title, body) print("\n===== 결과 =====") # print("본문 요약:\n", r["요약"]) print("제목과 본문 요약 유사도:", round(r["요약유사도"], 4)) print("제목과 본문 일치도(Top5 평균):", round(r["본문 일치도(Top5 평균)"], 4)) print("과장점수(log화):", round(r["과장점수_log10"], 4)) print("\n최종 제목 주의 점수는", r["최종 기사 점수"], "입니다") def run_ui(): import gradio as gr # ... (기존 내용 동일) def predict(title, body): r = run_once(title, body) final_score = r["최종 기사 점수"] grade = title_attention_index(final_score) return ( r["요약유사도"], r["본문 일치도(Top5 평균)"], r["과장점수"], final_score, grade, ) demo = gr.Interface( fn=predict, inputs=[ gr.Textbox(label="제목", lines=2), gr.Textbox(label="본문", lines=18, placeholder="여기에 기사 본문을 붙여넣으세요"), ], outputs=[ # gr.Textbox(label="요약", lines=10), gr.Number(label="요약유사도"), gr.Number(label="본문 일치도(Top5 평균)"), gr.Number(label="과장점수"), gr.Number(label="최종 기사 점수"), gr.Textbox(label="제목 주의 지수", interactive=False), ], title="제목 주의 지수", description=( "제목/본문을 입력하면 제목-본문 유사도, 과장 점수를 바탕으로 '제목 주의 지수'를 계산합니다.\n\n" "ℹ️ **자세한 설명이 궁금하다면 [여기를 클릭하세요](https://www.notion.so/25cb058cee088026badfcab340e9966d?source=copy_link)**" ), ) demo.launch(server_name="0.0.0.0", server_port=7861, share=True) if __name__ == "__main__": parser = argparse.ArgumentParser() parser.add_argument("--ui", action="store_true", help="Gradio UI 실행") args, _ = parser.parse_known_args() if args.ui: run_ui() else: run_cli()