diff --git a/app/analysis/text/naive_bayes_analyzer.py b/app/analysis/text/naive_bayes_analyzer.py index 0e05c93..746a72b 100644 --- a/app/analysis/text/naive_bayes_analyzer.py +++ b/app/analysis/text/naive_bayes_analyzer.py @@ -16,10 +16,13 @@ # --- 전처리 정규식 : data_science/SMSModel/train_sms.py의 정규화/피처 추출 로직과 반드시 동일하게 유지 --- # (학습 시 벡터라이저가 본 입력 분포와 서빙 시 입력 분포가 어긋나면 모델이 무의미해짐) -_RE_URL = re.compile(r"https?://\S+|[a-zA-Z0-9.-]+\.(kr|com|net|cyou|xyz|me|io|cc)\S*") -_RE_PHONE = re.compile(r"\d{2,4}-\d{3,4}-\d{4}") -_RE_LONG_NUM = re.compile(r"\b\d{6,}\b") -_RE_AMOUNT = re.compile(r"\d+[,\d]*원") +_RE_URL = re.compile(r"(?i)(? str: - text = _RE_URL.sub("", text) - text = _RE_PHONE.sub("<전화번호>", text) - text = _RE_LONG_NUM.sub("<긴숫자>", text) - text = _RE_AMOUNT.sub("<금액>", text) + parts = [] + last_end = 0 + for m in _RE_URL.finditer(text): + parts.append(_mask_pii(text[last_end:m.start()])) + parts.append("[URL]") + last_end = m.end() + parts.append(_mask_pii(text[last_end:])) + text = "".join(parts) + text = _RE_AMOUNT.sub("[AMOUNT]", text) text = _RE_FORMAT_ARTIFACT.sub(" ", text) return re.sub(r"\s+", " ", text).strip() +def _mask_pii(text: str) -> str: + text = _RE_RRN.sub("[RRN]", text) + text = _RE_CARD.sub("[CARD]", text) + text = _RE_PHONE.sub("[PHONE]", text) + text = _RE_ACCOUNT.sub("[ACCOUNT]", text) + text = _RE_EMAIL.sub("[EMAIL]", text) + return text + def _extract_struct_features(text: str) -> list: return [ int(bool(_RE_URL.search(text))), int(bool(_RE_SHORT_URL.search(text))), - int(bool(_RE_PHONE.search(text))), + int(bool(_RE_PHONE.search(text) or "[PHONE]" in text)), int(bool(_RE_AMOUNT.search(text))), int(bool(_RE_WEB_TAG.search(text))), int(len(text) > 100), diff --git a/data_science/SMSModel/phishing_model_artifact.pkl b/data_science/SMSModel/phishing_model_artifact.pkl index 3c26fe6..50425ed 100644 Binary files a/data_science/SMSModel/phishing_model_artifact.pkl and b/data_science/SMSModel/phishing_model_artifact.pkl differ diff --git a/data_science/SMSModel/phishing_vectorizer.pkl b/data_science/SMSModel/phishing_vectorizer.pkl index e753697..7b33aa8 100644 Binary files a/data_science/SMSModel/phishing_vectorizer.pkl and b/data_science/SMSModel/phishing_vectorizer.pkl differ diff --git a/data_science/SMSModel/train_sms.py b/data_science/SMSModel/train_sms.py index 265e9d6..fb23404 100644 --- a/data_science/SMSModel/train_sms.py +++ b/data_science/SMSModel/train_sms.py @@ -61,34 +61,20 @@ # ───────────────────────────────────────────────────────────────────────────── # 정규화 패턴 -# URL/전화번호/계좌번호(6자리+)/금액만 토큰화. -# 날짜·수량 등 일반 숫자 유지 — 전체 치환 시 정규화 후 중복 폭증 확인(1797건). +# Spring PiiMaskingService 토큰과 일치: [PHONE],[ACCOUNT],[CARD],[RRN],[EMAIL] +# 마스킹 순서: RRN→CARD→PHONE→ACCOUNT→EMAIL (Spring과 동일하게 유지) # ───────────────────────────────────────────────────────────────────────────── -_RE_URL = re.compile( - r"https?://\S+|[a-zA-Z0-9.-]+\.(kr|com|net|cyou|xyz|me|io|cc)\S*" -) -_RE_PHONE = re.compile(r"\d{2,4}-\d{3,4}-\d{4}") -_RE_LONG_NUM = re.compile(r"\b\d{6,}\b") -_RE_AMOUNT = re.compile(r"\d+[,\d]*원") +_RE_URL = re.compile(r"(?i)(? str: - text = _RE_URL.sub("", text) - text = _RE_PHONE.sub("<전화번호>", text) - text = _RE_LONG_NUM.sub("<긴숫자>", text) - text = _RE_AMOUNT.sub("<금액>", text) + parts = [] + last_end = 0 + for m in _RE_URL.finditer(text): + parts.append(_mask_pii(text[last_end:m.start()])) + parts.append("[URL]") + last_end = m.end() + parts.append(_mask_pii(text[last_end:])) + text = "".join(parts) + text = _RE_AMOUNT.sub("[AMOUNT]", text) text = _RE_FORMAT_ARTIFACT.sub(" ", text) return re.sub(r"\s+", " ", text).strip() +def _mask_pii(text: str) -> str: + text = _RE_RRN.sub("[RRN]", text) + text = _RE_CARD.sub("[CARD]", text) + text = _RE_PHONE.sub("[PHONE]", text) + text = _RE_ACCOUNT.sub("[ACCOUNT]", text) + text = _RE_EMAIL.sub("[EMAIL]", text) + return text + def _extract_struct_features(texts: pd.Series, has_url: pd.Series) -> np.ndarray: """ @@ -112,7 +111,7 @@ def _extract_struct_features(texts: pd.Series, has_url: pd.Series) -> np.ndarray return np.column_stack([ has_url.astype(int).values, # 0: URL 포함 texts.str.contains(_RE_SHORT_URL).astype(int).values, # 1: 단축URL (강신호) - texts.str.contains(_RE_PHONE).astype(int).values, # 2: 전화번호 + (texts.str.contains(_RE_PHONE) | texts.str.contains(r"\[PHONE\]", regex=True)).astype(int).values, # 2: 전화번호 texts.str.contains(_RE_AMOUNT).astype(int).values, # 3: 금액 texts.str.contains(_RE_WEB_TAG).astype(int).values, # 4: 통신사태그(역방향) (texts.str.len() > 100).astype(int).values, # 5: 100자 초과 @@ -443,7 +442,7 @@ def predict_risk_score( struct = np.array([[ int(bool(_RE_URL.search(text))), int(bool(_RE_SHORT_URL.search(text))), - int(bool(_RE_PHONE.search(text))), + int(bool(_RE_PHONE.search(text) or "[PHONE]" in text)), int(bool(_RE_AMOUNT.search(text))), int(bool(_RE_WEB_TAG.search(text))), int(len(text) > 100), diff --git a/pytest.ini b/pytest.ini index ac5a27c..70361c0 100644 --- a/pytest.ini +++ b/pytest.ini @@ -2,3 +2,4 @@ markers = integration: hits real external hosts over the network; excluded by default, run explicitly with -m integration addopts = -m "not integration" +asyncio_mode = auto diff --git a/tests/analysis/text/test_naive_bayes_analyzer.py b/tests/analysis/text/test_naive_bayes_analyzer.py index 542453f..779f670 100644 --- a/tests/analysis/text/test_naive_bayes_analyzer.py +++ b/tests/analysis/text/test_naive_bayes_analyzer.py @@ -25,8 +25,8 @@ def test_normalize_text_masks_url_and_amount(): text = "http://bit.ly/fake 계좌로 500,000원 즉시 입금하세요" normalized = nb._normalize_text(text) - assert "" in normalized - assert "<금액>" in normalized + assert "[URL]" in normalized + assert "[AMOUNT]" in normalized assert "http://" not in normalized diff --git a/tests/analysis/text/test_nb_masking_accuracy.py b/tests/analysis/text/test_nb_masking_accuracy.py new file mode 100644 index 0000000..a94039b --- /dev/null +++ b/tests/analysis/text/test_nb_masking_accuracy.py @@ -0,0 +1,93 @@ +import re +import pytest + +from app.analysis.text.naive_bayes_analyzer import analyze_text_with_naive_bayes + +# Spring PiiMaskingService와 동일한 패턴 +_RRN = re.compile(r"(? str: + parts = [] + last_end = 0 + for m in _URL.finditer(text): + parts.append(_mask_pii(text[last_end:m.start()])) + parts.append(m.group()) + last_end = m.end() + parts.append(_mask_pii(text[last_end:])) + return "".join(parts) + + +def _mask_pii(text: str) -> str: + text = _RRN.sub("[RRN]", text) + text = _CARD.sub("[CARD]", text) + text = _PHONE.sub("[PHONE]", text) + text = _ACCOUNT.sub("[ACCOUNT]", text) + text = _EMAIL.sub("[EMAIL]", text) + return text + + +SAMPLES = [ + "[국민은행] 계좌 110-1234-567890이 정지되었습니다. 즉시 010-1234-5678로 연락하세요.", + "고객님 명의로 이상 거래가 감지되었습니다. 즉시 확인하세요. http://bit.ly/fake", + "신한카드 비정상 결제 감지. 1234-5678-9012-3456 카드를 즉시 정지하세요.", + "[금융감독원] 명의도용 확인 요망. 010-9876-5432로 연락하세요.", + "계좌 100123456789에서 출금 시도가 감지되었습니다.", + "대출 승인 완료. 즉시 송금 바랍니다. 110-2345-678901", + "[검찰청] 귀하의 계좌가 범죄에 연루되었습니다. 즉시 확인하세요.", + "카드 도용 의심. 즉시 1588-1234로 신고하세요.", + "오늘 저녁 메뉴 뭐야?", + "내일 회의 몇 시야?", +] + + +@pytest.mark.asyncio +async def test_nb_masking_accuracy(): + results = [] + + for text in SAMPLES: + masked = mask(text) + original_result = await analyze_text_with_naive_bayes(text) + masked_result = await analyze_text_with_naive_bayes(masked) + + assert original_result["is_available"], "NB 모델 로드 실패 — 원문 추론 불가" + assert masked_result["is_available"], "NB 모델 로드 실패 — 마스킹 추론 불가" + + original_score = original_result["result"]["risk_score"] + masked_score = masked_result["result"]["risk_score"] + + diff = abs(original_score - masked_score) + + results.append({ + "original": text, + "masked": masked, + "original_score": original_score, + "masked_score": masked_score, + "diff": diff, + }) + + print(f"\n원문: {text[:50]}...") + print(f"마스킹: {masked[:50]}...") + print(f"원문 점수: {original_score} | 마스킹 점수: {masked_score} | 차이: {diff}") + + avg_diff = sum(r["diff"] for r in results) / len(results) + max_diff = max(r["diff"] for r in results) + over_threshold = [r for r in results if r["diff"] > 5] + + print(f"\n{'='*60}") + print(f"평균 점수 차이: {avg_diff:.1f}점") + print(f"최대 점수 차이: {max_diff}점") + print(f"5점 초과 케이스: {len(over_threshold)}건 / {len(results)}건") + + if over_threshold: + print("\n[5점 초과 케이스]") + for r in over_threshold: + print(f" 원문: {r['original'][:50]}") + print(f" 원문 점수: {r['original_score']} | 마스킹 점수: {r['masked_score']} | 차이: {r['diff']}") + + assert avg_diff <= 5, f"NB 정확도 하락이 허용 범위 초과: 평균 {avg_diff:.1f}점 차이" \ No newline at end of file