Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 25 additions & 9 deletions app/analysis/text/naive_bayes_analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)(?<!@)(?:https?://|www\.)[A-Za-z0-9\-._~:/?#\[\]@!$&'()*+;=%]+")
_RE_RRN = re.compile(r"(?<!\d)\d{6}[- ]\d{7}(?!\d)")
_RE_CARD = re.compile(r"(?<!\d)(?:\d{4}[- ]?\d{4}[- ]?\d{4}[- ]?\d{4}|\d{4}[- ]?\d{6}[- ]?\d{5})(?!\d)")
_RE_PHONE = re.compile(r"(?<!\d)(?:0\d{1,2}[- ]?\d{3,4}[- ]?\d{4}|0\d{9,10})(?!\d)")
_RE_ACCOUNT = re.compile(r"(?<!\d)\d{2,6}-\d{2,6}-\d{2,6}(?:-\d{1,6})?(?!\d)|(?<!\d)\d{10,14}(?!\d)")
_RE_EMAIL = re.compile(r"(?i)[A-Z0-9._%+\-]+@[A-Z0-9.\-]+\.[A-Z]{2,}")
_RE_AMOUNT = re.compile(r"\d+[,\d]*원")
_RE_FORMAT_ARTIFACT = re.compile(r"={2,}|■|□|▪|▫|●|○|\s-\s|\s:\s")
_RE_SHORT_URL = re.compile(r"bit\.ly|goo\.gl|tinyurl|gourl|ow\.ly|n\.bnuee|han\.gl|cutt\.ly")
_RE_WEB_TAG = re.compile(r"\[Web발신\]|\[국외발신\]|\[국제발신\]")
Expand All @@ -40,19 +43,32 @@


def _normalize_text(text: str) -> str:
text = _RE_URL.sub("<URL>", 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),
Expand Down
Binary file modified data_science/SMSModel/phishing_model_artifact.pkl
Binary file not shown.
Binary file modified data_science/SMSModel/phishing_vectorizer.pkl
Binary file not shown.
61 changes: 30 additions & 31 deletions data_science/SMSModel/train_sms.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,48 +61,47 @@

# ─────────────────────────────────────────────────────────────────────────────
# 정규화 패턴
# 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)(?<!@)(?:https?://|www\.)[A-Za-z0-9\-._~:/?#\[\]@!$&'()*+;=%]+")
_RE_RRN = re.compile(r"(?<!\d)\d{6}[- ]\d{7}(?!\d)")
_RE_CARD = re.compile(r"(?<!\d)(?:\d{4}[- ]?\d{4}[- ]?\d{4}[- ]?\d{4}|\d{4}[- ]?\d{6}[- ]?\d{5})(?!\d)")
_RE_PHONE = re.compile(r"(?<!\d)(?:0\d{1,2}[- ]?\d{3,4}[- ]?\d{4}|0\d{9,10})(?!\d)")
_RE_ACCOUNT = re.compile(r"(?<!\d)\d{2,6}-\d{2,6}-\d{2,6}(?:-\d{1,6})?(?!\d)|(?<!\d)\d{10,14}(?!\d)")
_RE_EMAIL = re.compile(r"(?i)[A-Z0-9._%+\-]+@[A-Z0-9.\-]+\.[A-Z]{2,}")
_RE_AMOUNT = re.compile(r"\d+[,\d]*원")
_RE_FORMAT_ARTIFACT = re.compile(r"={2,}|■|□|▪|▫|●|○|\s-\s|\s:\s")

# has_short_url: phishing 27.8% vs normal 0.4% — 가장 강한 단독 신호
_RE_SHORT_URL = re.compile(
r"bit\.ly|goo\.gl|tinyurl|gourl|ow\.ly|n\.bnuee|han\.gl|cutt\.ly"
)
_RE_WEB_TAG = re.compile(r"\[Web발신\]|\[국외발신\]|\[국제발신\]")

# _extract_struct_features()가 반환하는 배열의 열 순서와 정확히 일치해야 함.
# SMSData.ipynb 피처 중요도 시각화에서 이 리스트를 그대로 import해서 쓴다.
STRUCT_FEATURE_NAMES = [
"has_url", # 0: URL 포함 여부
"has_short_url", # 1: 단축 URL (phishing 27.8% vs normal 0.4% — 가장 강한 신호)
"has_phone", # 2: 전화번호 포함 여부
"has_amount", # 3: 금액 표현 포함 여부
"has_web_tag", # 4: 통신사 태그 (정상에서 더 높음 — 역방향 신호)
"is_long_text", # 5: 100자 초과 여부
]
_RE_SHORT_URL = re.compile(r"bit\.ly|goo\.gl|tinyurl|gourl|ow\.ly|n\.bnuee|han\.gl|cutt\.ly")
_RE_WEB_TAG = re.compile(r"\[Web발신\]|\[국외발신\]|\[국제발신\]")


# ─────────────────────────────────────────────────────────────────────────────
# 전처리
# ─────────────────────────────────────────────────────────────────────────────

def _normalize_text(text: str) -> str:
text = _RE_URL.sub("<URL>", 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:
"""
Expand All @@ -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자 초과
Expand Down Expand Up @@ -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),
Expand Down
1 change: 1 addition & 0 deletions pytest.ini
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 2 additions & 2 deletions tests/analysis/text/test_naive_bayes_analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<URL>" in normalized
assert "<금액>" in normalized
assert "[URL]" in normalized
assert "[AMOUNT]" in normalized
assert "http://" not in normalized


Expand Down
93 changes: 93 additions & 0 deletions tests/analysis/text/test_nb_masking_accuracy.py
Original file line number Diff line number Diff line change
@@ -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"(?<!\d)\d{6}[- ]\d{7}(?!\d)")
_CARD = re.compile(r"(?<!\d)(?:\d{4}[- ]?\d{4}[- ]?\d{4}[- ]?\d{4}|\d{4}[- ]?\d{6}[- ]?\d{5})(?!\d)")
_PHONE = re.compile(r"(?<!\d)(?:0\d{1,2}[- ]?\d{3,4}[- ]?\d{4}|0\d{9,10})(?!\d)")
_ACCOUNT = re.compile(r"(?<!\d)\d{2,6}-\d{2,6}-\d{2,6}(?:-\d{1,6})?(?!\d)|(?<!\d)\d{10,14}(?!\d)")
_EMAIL = re.compile(r"(?i)[A-Z0-9._%+\-]+@[A-Z0-9.\-]+\.[A-Z]{2,}")
_URL = re.compile(r"(?i)(?<!@)(?:https?://|www\.)[A-Za-z0-9\-._~:/?#\[\]@!$&'()*+;=%]+")


def mask(text: str) -> 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)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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}점 차이"
Loading