Skip to content
Open
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
5 changes: 3 additions & 2 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,9 @@ requirements-dev.txt
secrets
data_science/**
!data_science/SMSModel/
!data_science/SMSModel/phishing_model_artifact.pkl
!data_science/SMSModel/phishing_vectorizer.pkl
!data_science/SMSModel/artifacts/
!data_science/SMSModel/artifacts/phishing_model_artifact.pkl
!data_science/SMSModel/artifacts/phishing_vectorizer.pkl
*.ipynb
*.csv
*.png
Expand Down
4 changes: 2 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,5 @@ MOCK_SECURITY_API=false
RABBITMQ_URL=amqp://safefam:safefam-local@localhost:5672/
RABBITMQ_CONSUMER_ENABLED=false

NAIVE_BAYES_MODEL_PATH=data_science/SMSModel/phishing_model_artifact.pkl
NAIVE_BAYES_VECTORIZER_PATH=data_science/SMSModel/phishing_vectorizer.pkl
NAIVE_BAYES_MODEL_PATH=data_science/SMSModel/artifacts/phishing_model_artifact.pkl
NAIVE_BAYES_VECTORIZER_PATH=data_science/SMSModel/artifacts/phishing_vectorizer.pkl
8 changes: 6 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,13 @@ ENV NAIVE_BAYES_VECTORIZER_PATH=/app/models/phishing_vectorizer.pkl
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# Fail the image build early when the Kiwi wheel cannot be imported or its
# morphological analyzer cannot be initialized in the target architecture.
RUN python -c "from kiwipiepy import Kiwi; assert Kiwi().tokenize('installation check')"

COPY app app
COPY data_science/SMSModel/phishing_model_artifact.pkl models/phishing_model_artifact.pkl
COPY data_science/SMSModel/phishing_vectorizer.pkl models/phishing_vectorizer.pkl
COPY data_science/SMSModel/artifacts/phishing_model_artifact.pkl models/phishing_model_artifact.pkl
COPY data_science/SMSModel/artifacts/phishing_vectorizer.pkl models/phishing_vectorizer.pkl
Comment on lines +13 to +19

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check whether the committed vectorizer artifact references the Kiwi tokenizer.
fd -t f 'phishing_vectorizer.pkl' --exec sh -c 'strings "$1" | grep -in "kiwi\|tokeniz" | head -n 20' _ {}

Repository: SafeFam/SafeFam_AI

Length of output: 194


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf 'Files of interest:\n'
git ls-files | grep -E '(^|/)(Dockerfile)$|phishing_vectorizer\.pkl$|kiwi_tokenizer\.py$|analysis/text/|smsmodel|SMSModel' | sed -n '1,200p'

printf '\nDockerfile relevant section:\n'
cat -n Dockerfile | sed -n '1,80p'

printf '\nKiwi tokenizer file:\n'
fd -t f 'kiwi_tokenizer\.py$' -x sh -c 'echo "--- $1"; cat -n "$1"' sh {}

Repository: SafeFam/SafeFam_AI

Length of output: 14541


Copy the Kiwi tokenizer package into the runtime image.

data_science/SMSModel/tokenization/kiwi_tokenizer.py defines kiwi_tokenize, and the vectorizer model was built with that callable. The Dockerfile only runs KIWI during build, then copies app, the artifact files, and models, so data_science/SMSModel/tokenization/__init__.py and data_science/SMSModel/tokenization/kiwi_tokenizer.py are absent at runtime and model loading can fail with ModuleNotFoundError.

Copy the tokenization package into the image, or move kiwi_tokenize under a path already included at runtime.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Dockerfile` around lines 13 - 19, Ensure the runtime image includes the
module path required by the serialized vectorizer’s kiwi_tokenize callable.
Update the Dockerfile COPY steps to include
data_science/SMSModel/tokenization/__init__.py and kiwi_tokenizer.py, or
relocate kiwi_tokenize under an already-copied package while preserving its
import path at model load time.


RUN useradd --create-home --shell /usr/sbin/nologin safefam \
&& chown -R safefam:safefam /app
Expand Down
2 changes: 1 addition & 1 deletion SCORING_PIPELINE_CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@

**`app/service/security/naive_bayes_text_analyzer.py`** (신규)

- 기존에 학습돼 저장소에 커밋되어 있던 `data_science/SMSModel/phishing_model_artifact.pkl`(CalibratedClassifierCV + ComplementNB), `phishing_vectorizer.pkl`을 로드
- 기존에 학습돼 저장소에 커밋되어 있던 `data_science/SMSModel/artifacts/phishing_model_artifact.pkl`(CalibratedClassifierCV + ComplementNB), `phishing_vectorizer.pkl`을 로드
- 전처리(URL/전화번호/금액 마스킹, 6개 구조적 피처)는 학습 스크립트(`train_sms.py`)와 동일하게 재구현 — 학습/서빙 피처 불일치 방지
- 모델 로드 실패 시 `UNKNOWN` 등급 + 에러 메시지로 fail-safe 처리 (SAFE로 오판하지 않음)
- `requirements.txt`에 `scikit-learn`, `scipy`, `numpy`, `joblib` 추가
Expand Down
154 changes: 86 additions & 68 deletions app/analysis/text/naive_bayes_analyzer.py
Original file line number Diff line number Diff line change
@@ -1,149 +1,167 @@
import re
# 사전 학습된 Naive Bayes 모델을 이용한 SMS 피싱 분석기
from __future__ import annotations

import logging

import numpy as np
from scipy.sparse import csr_matrix, hstack

from app.analysis.risk_policy import determine_text_risk_grade
from app.analysis.text.preprocessing import (
extract_struct_features,
normalize_text,
)
from app.core.config import settings


logger = logging.getLogger(__name__)


MODEL_PATH = settings.NAIVE_BAYES_MODEL_PATH
VECTORIZER_PATH = settings.NAIVE_BAYES_VECTORIZER_PATH

# --- 전처리 정규식 : data_science/SMSModel/train_sms.py의 정규화/피처 추출 로직과 반드시 동일하게 유지 ---
# (학습 시 벡터라이저가 본 입력 분포와 서빙 시 입력 분포가 어긋나면 모델이 무의미해짐)
_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발신\]|\[국외발신\]|\[국제발신\]")

DEFAULT_ANALYSIS_RESULT = {
"grade": "UNKNOWN",
"risk_score": 0,
"is_suspected_phishing": False,
"error_message": "나이브 베이즈 모델을 로드하지 못해 위험도를 판정할 수 없습니다."
"error_message": (
"나이브 베이즈 모델을 로드하지 못해 "
"위험도를 판정할 수 없습니다."
),
}

_model = None
_vectorizer = None
_threshold = None
_classes = None
_load_error: str | None = None
_load_attempted = False

_load_error: str | None = None

def _normalize_text(text: str) -> str:
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) or "[PHONE]" in text)),
int(bool(_RE_AMOUNT.search(text))),
int(bool(_RE_WEB_TAG.search(text))),
int(len(text) > 100),
]
_load_attempted = False


def _load_artifacts() -> None:
"""FastAPI 프로세스 당 1회만 시도. 실패 시 재시도하지 않고 fail-safe 응답으로 대체."""
global _model, _vectorizer, _threshold, _classes, _load_error, _load_attempted
"""
모델과 벡터라이저를 프로세스당 한 번만 로드

로드에 실패해도 API 서버 전체를 중단시키지 않고, 이후 분석 요청에서
UNKNOWN 결과를 반환할 수 있도록 오류 상태만 저장
"""
global _model
global _vectorizer
global _threshold
global _classes
global _load_error
global _load_attempted

if _load_attempted:
return

_load_attempted = True
Comment on lines 58 to 61

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Concurrent first requests can receive a spurious unavailable result.

_load_artifacts sets _load_attempted = True before the load starts. A second request that arrives while joblib.load is still running returns at Line 59 with _model still None. analyze_text_with_naive_bayes then reports is_available: False even though the artifact loads correctly. FastAPI runs sync work in a thread pool, so this window is reachable under concurrent traffic at startup.

Guard the load with a lock and re-check the flag inside it.

🔒 Proposed fix
 import logging
+import threading
 
 import numpy as np
+_load_lock = threading.Lock()
 _load_attempted = False
-    if _load_attempted:
-        return
-
-    _load_attempted = True
-
-    try:
-        import joblib
-
-        artifact = joblib.load(MODEL_PATH)
-        vectorizer = joblib.load(VECTORIZER_PATH)
-
-        # 모든 값이 정상적으로 읽힌 이후 전역 상태를 갱신
-        _model = artifact["model"]
-        _threshold = artifact["threshold"]
-        _classes = artifact["classes"]
-        _vectorizer = vectorizer
-        _load_error = None
-
-        logger.info(
-            "[NaiveBayes] 모델 로드 완료 (threshold=%s)",
-            _threshold,
-        )
-    except Exception as exception:
-        _load_error = type(exception).__name__
-
-        logger.error(
-            "[NaiveBayes] 모델 로드 실패. error_type=%s",
-            _load_error,
-        )
+    if _load_attempted:
+        return
+
+    with _load_lock:
+        if _load_attempted:
+            return
+
+        try:
+            import joblib
+
+            artifact = joblib.load(MODEL_PATH)
+            vectorizer = joblib.load(VECTORIZER_PATH)
+
+            # 모든 값이 정상적으로 읽힌 이후 전역 상태를 갱신
+            _model = artifact["model"]
+            _threshold = artifact["threshold"]
+            _classes = artifact["classes"]
+            _vectorizer = vectorizer
+            _load_error = None
+
+            logger.info(
+                "[NaiveBayes] 모델 로드 완료 (threshold=%s)",
+                _threshold,
+            )
+        except Exception as exception:
+            _load_error = type(exception).__name__
+
+            logger.error(
+                "[NaiveBayes] 모델 로드 실패. error_type=%s",
+                _load_error,
+            )
+        finally:
+            _load_attempted = True
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if _load_attempted:
return
_load_attempted = True
if _load_attempted:
return
with _load_lock:
if _load_attempted:
return
try:
import joblib
artifact = joblib.load(MODEL_PATH)
vectorizer = joblib.load(VECTORIZER_PATH)
# 모든 값이 정상적으로 읽힌 이후 전역 상태를 갱신
_model = artifact["model"]
_threshold = artifact["threshold"]
_classes = artifact["classes"]
_vectorizer = vectorizer
_load_error = None
logger.info(
"[NaiveBayes] 모델 로드 완료 (threshold=%s)",
_threshold,
)
except Exception as exception:
_load_error = type(exception).__name__
logger.error(
"[NaiveBayes] 모델 로드 실패. error_type=%s",
_load_error,
)
finally:
_load_attempted = True
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/analysis/text/naive_bayes_analyzer.py` around lines 58 - 61, Update
_load_artifacts to synchronize concurrent callers with a lock: acquire the lock
before checking _load_attempted, re-check the flag after acquiring it, and keep
the flag unset until the artifact load completes or otherwise ensure waiting
callers do not return while _model is still unavailable. Preserve the existing
one-time loading behavior and have concurrent startup requests observe the
successfully loaded model.


try:
import joblib

artifact = joblib.load(MODEL_PATH)
vectorizer = joblib.load(VECTORIZER_PATH)

# 모든 값이 정상적으로 읽힌 이후 전역 상태를 갱신
_model = artifact["model"]
_threshold = artifact["threshold"]
_classes = artifact["classes"]
_vectorizer = joblib.load(VECTORIZER_PATH)
logger.info(f"[NaiveBayes] 모델 로드 완료 (threshold={_threshold})")
_vectorizer = vectorizer
_load_error = None

logger.info(
"[NaiveBayes] 모델 로드 완료 (threshold=%s)",
_threshold,
)
except Exception as exception:
_load_error = type(exception).__name__

logger.error(
"[NaiveBayes] 모델 로드 실패. error_type=%s",
_load_error,
)


def is_model_loaded() -> bool:
"""Naive Bayes 모델을 사용할 수 있는지 반환"""
_load_artifacts()
return _model is not None


# 사전 학습된 나이브 베이즈(ComplementNB + isotonic 보정) 모델로 문자 메시지의 1차 위험도를 산출
async def analyze_text_with_naive_bayes(text: str) -> dict:
"""사전 학습된 Naive Bayes 모델로 SMS의 피싱 위험도를 분석"""
_load_artifacts()

if _model is None:
return {
"engine": "naive_bayes",
"is_available": False,
"result": dict(DEFAULT_ANALYSIS_RESULT, error_message=_load_error or DEFAULT_ANALYSIS_RESULT["error_message"])
"result": dict(
DEFAULT_ANALYSIS_RESULT,
error_message=(
_load_error
or DEFAULT_ANALYSIS_RESULT["error_message"]
),
),
}
Comment on lines +103 to 110

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Do not return the exception class name as the user-facing error message.

_load_error holds type(exception).__name__, for example FileNotFoundError or UnpicklingError. This value is placed directly in result["error_message"], which the API returns to clients. The value exposes internal failure detail and breaks the localized message contract used everywhere else in DEFAULT_ANALYSIS_RESULT. Keep the exception type in the log only.

🛡️ Proposed fix
             "result": dict(
                 DEFAULT_ANALYSIS_RESULT,
-                error_message=(
-                    _load_error
-                    or DEFAULT_ANALYSIS_RESULT["error_message"]
-                ),
+                error_message=DEFAULT_ANALYSIS_RESULT["error_message"],
             ),
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
"result": dict(
DEFAULT_ANALYSIS_RESULT,
error_message=(
_load_error
or DEFAULT_ANALYSIS_RESULT["error_message"]
),
),
}
"result": dict(
DEFAULT_ANALYSIS_RESULT,
error_message=DEFAULT_ANALYSIS_RESULT["error_message"],
),
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/analysis/text/naive_bayes_analyzer.py` around lines 103 - 110, Update the
result construction in the analyzer method so result["error_message"] always
uses DEFAULT_ANALYSIS_RESULT["error_message"] rather than _load_error; retain
_load_error only for internal logging where the exception type belongs.


try:
import numpy as np
from scipy.sparse import csr_matrix, hstack
# 학습과 동일한 공통 전처리를 적용
normalized_text = normalize_text(text)

text_norm = _normalize_text(text)
struct = np.array([_extract_struct_features(text)])
struct_features = np.asarray(
[extract_struct_features(text)],
dtype=np.int8,
)
Comment on lines +116 to +119

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Compare the dataset has_url column against the shared URL regex.
fd -t f 'phishing_total_dataset_2705.csv' | head -n 1 | while IFS= read -r csv; do
python - "$csv" <<'PY'
import sys, pandas as pd
sys.path.insert(0, ".")
from app.analysis.text.preprocessing import URL_PATTERN
df = pd.read_csv(sys.argv[1])
detected = df["text"].astype(str).str.contains(URL_PATTERN)
declared = df["has_url"].astype(bool)
print("rows:", len(df), "mismatches:", int((detected != declared).sum()))
print(df.loc[detected != declared, ["text", "has_url"]].head(10).to_string())
PY
done

Repository: SafeFam/SafeFam_AI

Length of output: 275


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== files ==="
fd -t f 'naive_bayes_analyzer.py|train_sms.py|naive_bayes.py|preprocessing.py|phishing_total_dataset_2705.csv' .

echo
echo "=== relevant snippets ==="
for f in app/analysis/text/naive_bayes_analyzer.py data_science/SMSModel/train_sms.py data_science/SMSModel/modeling/naive_bayes.py app/analysis/text/preprocessing.py; do
  if [ -f "$f" ]; then
    echo "--- $f ---"
    ast-grep outline "$f" --match 'extract_struct_features|extract_struct_feature_matrix|_build_feature_matrix|URL_PATTERN' --view expanded || true
    rg -n -C 4 'def extract_struct_features|def extract_struct_feature_matrix|def _build_feature_matrix|has_url|URL_PATTERN' "$f"
  fi
done

echo
echo "=== dataset has_url and text-based count (no pandas) ==="
csv="$(fd -t f 'phishing_total_dataset_2705.csv' . | head -n 1 || true)"
if [ -n "${csv:-}" ]; then
  python3 - "$csv" <<'PY'
import sys, re
csv=sys.argv[1]
with open(csv, encoding='utf-8') as f:
    header=f.readline().strip().split(',')
    has_text=header.index('text')
    has_url=header.index('has_url')
    rows=list(csv.strip().splitlines())
print("csv:", csv, "rows:", len(rows)-1)
# read exact columns without pandas
PY
  url_pattern="$(python3 -c "import ast, pathlib, re; f=pathlib.Path('app/analysis/text/preprocessing.py'); tree=ast.parse(f.read_text()); constants={n.value.id:'SUFFIX_PATTERN' in n.value.value if isinstance(n.value, ast.BinOp) else n.value.value for n in ast.walk(tree) if isinstance(n, ast.Assign)}; print(repr(__import__('glob').glob('SUFFIX_PATTERN')[0] if False else ''))")"
  python3 - "$csv" <<'PY'
import sys, re, ast, pathlib
csv=sys.argv[1]
mod=pathlib.Path('app/analysis/text/preprocessing.py')
source=mod.read_text()
tree=ast.parse(source)
for node in ast.walk(tree):
    if isinstance(node, ast.Assign):
        for target in node.targets:
            if isinstance(target, ast.Name) and target.id == 'URL_PATTERN':
                print("URL_PATTERN=", ast.literal_eval(node.value.value))
PY
  python3 - <<'PY' "$csv"
import sys, re, ast, pathlib
csv=sys.argv[1]
mod=pathlib.Path('app/analysis/text/preprocessing.py')
source=mod.read_text()
tree=ast.parse(source)
url_re=None
for node in ast.walk(tree):
    if isinstance(node, ast.Assign):
        for target in node.targets:
            if isinstance(target, ast.Name) and target.id == 'URL_PATTERN':
                url_re=ast.literal_eval(node.value.value)
p=re.compile(url_re)
with open(csv, encoding='utf-8') as f:
    header=f.readline().strip().split(',')
    has_text=header.index('text')
    has_url=header.index('has_url')
    rows=list(csv.strip().splitlines())
print("rows:", len(rows)-1, "url_re:", url_re)
mism=0
examples=[]
for i,row in enumerate(rows, start=2):
    fields=row.split(',', header.index('has_url')+1)
    txt=fields[has_text]
    declared=bool(int(fields[has_url])) if fields[has_url] else False
    detected=bool(p.search(txt))
    if declared != detected:
        mism += 1
        if len(examples) < 10:
            examples.append((i, txt[:200], declared, detected))
print("mismatches:", mism)
for rec in examples:
    print(rec)
PY
else
  echo "no dataset found"
fi

Repository: SafeFam/SafeFam_AI

Length of output: 683


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== source snippets ==="
for f in app/analysis/text/naive_bayes_analyzer.py data_science/SMSModel/train_sms.py data_science/SMSModel/modeling/naive_bayes.py app/analysis/text/preprocessing.py; do
  if [ -f "$f" ]; then
    echo "--- $f ---"
    wc -l "$f"
    rg -n -C 4 'extract_struct_features|extract_struct_feature_matrix|build_feature_matrix|_build_feature_matrix|has_url|URL_PATTERN' "$f" || true
  fi
done

echo
echo "=== URL_PATTERN ==="
cd app/analysis/text
python3 - <<'PY'
import ast
from pathlib import Path
source = Path('preprocessing.py').read_text()
tree = ast.parse(source)
for node in ast.walk(tree):
    if isinstance(node, ast.Assign):
        for target in node.targets:
            if isinstance(target, ast.Name) and target.id == 'URL_PATTERN':
                print(ast.literal_eval(node.value.value))
PY

echo
echo "=== dataset count without pandas ==="
csv="$(fd -t f 'phishing_total_dataset_2705.csv' data_science/Data/SMSData | head -n 1 || true)"
if [ -n "${csv:-}" ]; then
  python3 - "$csv" <<'PY'
import re, ast, sys, pathlib
csv = sys.argv[1]
source = pathlib.Path('app/analysis/text/preprocessing.py').read_text()
tree = ast.parse(source)
url_re = None
for node in ast.walk(tree):
    if isinstance(node, ast.Assign):
        for target in node.targets:
            if isinstance(target, ast.Name) and target.id == 'URL_PATTERN':
                url_re = ast.literal_eval(node.value.value)
print("url_re:", url_re)
p = re.compile(url_re)
url_index = None
with open(csv, encoding='utf-8') as f:
    header = f.readline().strip().split(',')
    for i, h in enumerate(header):
        if h.startswith('contains_url') or h == 'has_url' or h == 'has_url?' or h == 'has_url_':
            url_index = i
            break
if url_index is None:
    print("no contains_url/has_url column")
    sys.exit(0)
text_index = header.index('text') if 'text' in header else -1
rows = []
for line in f:
    fields = line.strip().split(',', header.index('contains_url?') + 1)
    text = fields[text_index]
    rows.append((bool(p.search(text)), bool(int(fields[url_index]))))
same = sum(a == b for a, b in rows)
print("rows:", len(rows), "same:", same, "mismatches:", len(rows) - same)
mismatches = [(a,b) for a,b in rows if a != b]
if mismatches:
    print("mismatch_fraction:", len(mismatches)/len(rows))
print("first_mismatches:", mismatches[:10])
PY
fi

Repository: SafeFam/SafeFam_AI

Length of output: 8073


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
import re, sys, ast, pathlib
csv = sys.argv[1]
url_re = r"(?i)(?<!@)(?:https?://|www\.)[A-Za-z0-9\-._~:/?#\[\]@!$&'()*+;=%]+"
p = re.compile(url_re)
try:
    idx = next(i for i, h in enumerate(open(csv, encoding='utf-8').readline().strip().split(',')) if h == 'has_url')
except StopIteration:
    print("missing has_url")
    sys.exit(0)
with open(csv, encoding='utf-8') as f:
    header = f.readline().strip().split(',')
    text_idx = header.index('text')
    row_count = 0
    same = 0
    examples = []
    for line in f:
        row_count += 1
        fields = line.rstrip('\n').split(',', idx + 1)
        detected = bool(p.search(fields[text_idx]))
        declared = bool(int(fields[idx])) if fields[idx] else False
        if detected != declared:
            if len(examples) < 10:
                examples.append((row_count, decoded(fields[text_idx])[1][100:], declared, detected))
        else:
            same += 1
def decoded(s):
    try: return bytes.fromhex(s).decode('utf-8', 'replace')
    except Exception: return s
print("rows:", row_count, "same:", same, "mismatches:", row_count - same)
for e in examples:
    print(e)
PY
data_science/Data/SMSData/phishing_total_dataset_2705.csv

Repository: SafeFam/SafeFam_AI

Length of output: 265


🏁 Script executed:

#!/bin/bash
set -euo pipefail

csv="$(fd -t f 'phishing_total_dataset_2705.csv' . | head -n 1 || true)"

echo "=== dataset header ==="
if [ -n "${csv:-}" ]; then
  python3 - "$csv" <<'PY'
import sys
path = sys.argv[1]
with open(path, encoding='utf-8') as f:
    line = f.readline()
print(line.strip())
PY
  echo "url-pattern match counts"
  python3 - "$csv" <<'PY'
import re, sys
p = re.compile(r"(?i)(?<!@)(?:https?://|www\.)[A-Za-z0-9\-._~:/?#\[\]@!$&'()*+;=%]+")
path = sys.argv[1]
with open(path, encoding='utf-8') as f:
    header = f.readline().strip().split(',')
print("header_count=", len(header), "contains_url_position=", next((i,header[i]) for i,h in enumerate(header) if h=="contains_url?"), sep="#")
text_i=header.index('text')
contains_url_i=header.index('contains_url?')
total=0; ppos=0; npos=0; mism=0
for i,line in enumerate(f,2):
    total += 1
    fields=line.rstrip('\n').split(',')
    text=fields[text_i]
    declared=bool(int(fields[contains_url_i])) if fields[contains_url_i] else False
    detected=bool(p.search(text))
    if declared:
        ppos +=1
    else:
        npos +=1
    if declared != detected:
        mism +=1
        if mism <= 5:
            print(f"row {i}: txt_len={len(text)} declared={declared} detected={detected} txt={text[:120]}")
print("total_rows", total, "pos_declared", ppos, "neg_declared", npos, "same", total-mism, "mismatches", mism)
PY
fi

Repository: SafeFam/SafeFam_AI

Length of output: 323


🏁 Script executed:

#!/bin/bash
set -euo pipefail

csv="$(fd -t f 'phishing_total_dataset_2705.csv' . | head -n 1 || true)"

echo "=== dataset header =="
if [ -n "${csv:-}" ]; then
  python3 - "$csv" <<'PY'
import sys
path = sys.argv[1]
with open(path, encoding='utf-8') as f:
    line = f.readline()
print(line.strip())
print("has_url position:", next((i, h) for i, h in enumerate(line.strip().split(',')) if h == 'has_url'))
PY
  echo "=== url-pattern vs has_url =="
  python3 - "$csv" <<'PY'
import re, sys, ast, pathlib
path = sys.argv[1]
url_re = r"(?i)(?<!@)(?:https?://|www\.)[A-Za-z0-9\-._~:/?#\[\]@!$&'()*+;=%]+"
p = re.compile(url_re)
with open(path, encoding='utf-8') as f:
    header = f.readline().strip().split(',')
text_i = header.index('text')
has_url_i = header.index('has_url')
total = same = mism = 0
examples = []
for i, line in enumerate(f, 2):
    total += 1
    fields = line.rstrip('\n').split(',')
    txt = fields[text_i]
    declared = bool(int(fields[has_url_i])) if fields[has_url_i] else False
    detected = bool(p.search(txt))
    if declared == detected:
        same += 1
    else:
        mism += 1
        if len(examples) < 10:
            examples.append((i, len(txt), declared, detected, txt[:80]))
print("rows", total)
print("same", same)
print("mismatches", mism)
print("mismatches_fraction", mism / total)
for ex in examples:
    print(ex)
PY
fi

Repository: SafeFam/SafeFam_AI

Length of output: 380


🏁 Script executed:

#!/bin/bash
set -euo pipefail

csv="$(fd -t f 'phishing_total_dataset_2705.csv' . | head -n 1 || true)"

echo "=== column indices for URL_PATTERN regex used in preprocessing.py =="
python3 - "$csv" <<'PY'
import re, sys
path = sys.argv[1]
url_re = r"(?i)(?<!@)(?:https?://|www\.)[A-Za-z0-9\-._~:/?#\[\]@!$&'()*+;=%]+"
p = re.compile(url_re)
with open(path, encoding='utf-8') as f:
    header = f.readline().strip().split(',')
print(header)
text_i = next(i for i, h in enumerate(header) if h == 'text')
has_url_i = next(i for i, h in enumerate(header) if h == 'has_url')
text_escaped_re = re.escape(header[text_i])
has_url_escaped_re = re.escape(header[has_url_i])
print(text_i)
print(has_url_i)
match = p.search(header[has_url_i])
print(match) if match else print("URL_PATTERN matches header's has_url column")
PY

echo "=== raw head =="
python3 - "$csv" <<'PY'
import sys
path = sys.argv[1]
with open(path, encoding='utf-8', newline='') as f:
    for i in range(5):
        print(f"line[{i}] repr={next(f).strip()!r}")
PY

echo "=== URL_FEATURE from contains_url-like fields if any =="
python3 - "$csv" <<'PY'
import re, sys
path = sys.argv[1]
with open(path, encoding='utf-8') as f:
    header = f.readline().strip().split(',')
text_i = next(i for i, h in enumerate(header) if h == 'text')
for i,h in enumerate(header):
    if h.startswith('contains_url') or h in ('has_url', 'has_url_') or h == 'has_url?':
        print(f"column {i}: {h}")
PY

Repository: SafeFam/SafeFam_AI

Length of output: 365


🏁 Script executed:

#!/bin/bash
set -euo pipefail

csv="$(fd -t f 'phishing_total_dataset_2705.csv' . | head -n 1 || true)"

echo "=== raw dataset head =="
python3 - "$csv" <<'PY'
import sys
path = sys.argv[1]
with open(path, encoding='utf-8-sig') as f:
    for i in range(6):
        line = next(f, None)
        print(f"line[{i}] len={len(line)} text={next(f, '')!r}")
PY

echo "=== exact mismatch report against column names =="
python3 - "$csv" <<'PY'
import re, sys, glob
path = sys.argv[1]
url_re = r"(?i)(?<!@)(?:https?://|www\.)[A-Za-z0-9\-._~:/?#\[\]@!$&'()*+;=%]+"
p = re.compile(url_re)
with open(path, encoding='utf-8-sig') as f:
    header = f.readline().strip().split(',')
print("header=[{}]" .format(','.join(repr(h) for h in header)))
text_i = next(i for i, h in enumerate(header) if h == 'text')
has_url_i = next(i for i, h in enumerate(header) if h == 'has_url')
total = same = mism = 0
examples = []
for i in range(2, 6)
    line = next(f, None)
    print(i, line.strip()[:100], text_i, has_url_i)
PY

Repository: SafeFam/SafeFam_AI

Length of output: 1289


🏁 Script executed:

#!/bin/bash
set -euo pipefail

csv="$(fd -t f 'phishing_total_dataset_2705.csv' . | head -n 1 || true)"

echo "=== exact mismatch report =="
python3 - "$csv" <<'PY'
import re, sys
path = sys.argv[1]
url_re = r"(?i)(?<!@)(?:https?://|www\.)[A-Za-z0-9\-._~:/?#\[\]@!$&'()*+;=%]+"
p = re.compile(url_re)
with open(path, encoding='utf-8-sig') as f:
    header = f.readline().strip().split(',')
text_i = header.index('text')
has_url_i = header.index('has_url')
total = same = mism = 0
examples = []
for row_no, row in enumerate(f, start=2):
    fields = row.rstrip('\n').split(',', has_url_i + 1)
    txt = fields[text_i]
    declared = bool(int(fields[has_url_i])) if fields[has_url_i] else False
    detected = bool(p.search(txt))
    if declared == detected:
        same += 1
    else:
        mism += 1
        if len(examples) < 10:
            examples.append((row_no, len(txt), declared, detected, txt[:120]))
print("rows", total)
print("same", same)
print("mismatches", mism)
print("mismatches_fraction", mism / total if total else 0)
for ex in examples:
    print(ex)
PY

Repository: SafeFam/SafeFam_AI

Length of output: 301


🏁 Script executed:

#!/bin/bash
set -euo pipefail

csv="$(fd -t f 'phishing_total_dataset_2705.csv' . | head -n 1 || true)"

python3 - "$csv" <<'PY'
import re, sys, pathlib
path = sys.argv[1]
url_re = r"(?i)(?<!@)(?:https?://|www\.)[A-Za-z0-9\-._~:/?#\[\]@!$&'()*+;=%]+"
p = re.compile(url_re)
with open(path, encoding='utf-8-sig') as f:
    header = f.readline().strip().split(',')
text_i = header.index('text')
has_url_i = header.index('has_url')
rows = []
for row_no, row in enumerate(f, start=2):
    fields = row.rstrip('\n').split(',', has_url_i + 1)
    txt = fields[text_i]
    declared = bool(int(fields[has_url_i])) if fields[has_url_i] else False
    detected = bool(p.search(txt))
    rows.append((detected, declared))
same = sum(a == b for a, b in rows)
mismatches = [(detected, declared) for detected, declared in rows if detected != declared]
print("rows", len(rows))
print("same", same)
print("mismatches", len(mismatches))
print("mismatches_fraction", len(mismatches) / len(rows) if rows else 0)
for detected, declared in mismatches[:10]:
    print("detected", detected, "declared", declared)
PY

Repository: SafeFam/SafeFam_AI

Length of output: 272


🏁 Script executed:

#!/bin/bash
set -euo pipefail

csv="$(fd -t f 'phishing_total_dataset_2705.csv' . | head -n 1 || true)"

python3 - "$csv" <<'PY'
import re, sys, pathlib
path = sys.argv[1]
url_re = r"(?i)(?<!@)(?:https?://|www\.)[A-Za-z0-9\-._~:/?#\[\]@!$&'()*+;=%]+"
p = re.compile(url_re)
with open(path, encoding='utf-8-sig') as f:
    header = f.readline().strip().split(',')
text_i = header.index('text')
has_url_i = header.index('has_url')
rows = []
for row_no, row in enumerate(f, start=2):
    fields = row.rstrip('\n').split(',', has_url_i + 1)
    txt = fields[text_i]
    declared = bool(int(fields[has_url_i])) if fields[has_url_i] else False
    detected = bool(p.search(txt))
    rows.append((row_no, detected, declared))
same = sum((detected == declared) for _, detected, declared in rows)
mismatches = [row for _, detected, declared in rows if detected != declared]
print("rows", len(rows))
print("same", same)
print("mismatches", len(mismatches))
print("mismatches_fraction", len(mismatches) / len(rows) if rows else 0)
for row_no, detected, declared in mismatches[:10]:
    print({"row": row_no, "detected": detected, "declared": declared})
PY

Repository: SafeFam/SafeFam_AI

Length of output: 272


🏁 Script executed:

#!/bin/bash
set -euo pipefail

csv="$(fd -t f 'phishing_total_dataset_2705.csv' . | head -n 1 || true)"

python3 <<'PY' "$csv"
import re, sys, pathlib
path = sys.argv[1]
url_re = r"(?i)(?<!@)(?:https?://|www\.)[A-Za-z0-9\-._~:/?#\[\]@!$&'()*+;=%]+"
p = re.compile(url_re)
with open(path, encoding='utf-8-sig') as f:
    header = f.readline().strip().split(',')
text_i = header.index('text')
has_url_i = header.index('has_url')
rows = []
for row_no, row in enumerate(f, start=2):
    fields = row.rstrip('\n').split(',', has_url_i + 1)
    txt = fields[text_i]
    declared = bool(int(fields[has_url_i])) if fields[has_url_i] else False
    detected = bool(p.search(txt))
    rows.append((row_no, detected, declared))
same = sum((detected == declared) for _, detected, declared in rows)
mismatches = [row for _, detected, declared in rows if detected != declared]
print("rows", len(rows))
print("same", same)
print("mismatches", len(mismatches))
print("mismatches_fraction", len(mismatches) / len(rows) if rows else 0)
for row_no, detected, declared in mismatches[:10]:
    print({"row": row_no, "detected": detected, "declared": declared})
PY

Repository: SafeFam/SafeFam_AI

Length of output: 395


🏁 Script executed:

#!/bin/bash
set -euo pipefail

csv="$(fd -t f 'phishing total dataset 2705.csv' data_science/Data/SMSData | head -n 1 || true)"

python3 - "$csv" <<'PY'
import re, sys, pathlib
path = sys.argv[1]
url_re = r"(?i)(?<!@)(?:https?://|www\.)[A-Za-z0-9\-._~:/?#\[\]@!$&'()*+;=%]+"
p = re.compile(url_re)
with open(path, encoding='utf-8-sig') as f:
    header = f.readline().strip().split(',')
print("csv", path)
print("header", header)
text_i = header.index('text')
has_url_i = header.index('has_url')
rows = []
for row_no, row in enumerate(f, start=2):
    fields = row.rstrip('\n').split(',', has_url_i + 1)
    txt = fields[text_i]
    declared = bool(int(fields[has_url_i])) if fields[has_url_i] else False
    detected = bool(p.search(txt))
    rows.append((row_no, len(txt), detected, declared))
same = sum((detected == declared) for _, detected, declared in rows)
mismatches = [row for _, detected, declared in rows if detected != declared]
print("rows", len(rows))
print("same", same)
print("mismatches", len(mismatches))
print("mismatches_fraction", len(mismatches) / len(rows) if rows else 0)
for row_no, txt_len, detected, declared in mismatches[:10]:
    print({"row": row_no, "text_length": txt_len, "detected": detected, "declared": declared})
PY

Repository: SafeFam/SafeFam_AI

Length of output: 288


🏁 Script executed:

#!/bin/bash
set -euo pipefail

csv="$(fd -t f 'phishing_total_dataset_2705.csv' . | head -n 1 || true)"

python3 - "$csv" <<'PY'
import re, sys, pathlib
path = sys.argv[1]
url_re = r"(?i)(?<!@)(?:https?://|www\.)[A-Za-z0-9\-._~:/?#\[\]@!$&'()*+;=%]+"
p = re.compile(url_re)
with open(path, encoding='utf-8-sig') as f:
    header = f.readline().strip().split(',')
print("csv", path)
print("header", header)
text_i = header.index('text')
has_url_i = header.index('has_url')
rows = []
for row_no, row in enumerate(f, start=2):
    fields = row.rstrip('\n').split(',', has_url_i + 1)
    txt = fields[text_i]
    declared = bool(int(fields[has_url_i])) if fields[has_url_i] else False
    detected = bool(p.search(txt))
    rows.append((row_no, len(txt), detected, declared))
same = sum((detected == declared) for _, detected, declared in rows)
mismatches = [row for _, detected, declared in rows if detected != declared]
print("rows", len(rows))
print("same", same)
print("mismatches", len(mismatches))
print("mismatches_fraction", len(mismatches) / len(rows) if rows else 0)
for row_no, txt_len, detected, declared in mismatches[:10]:
    print({"row": row_no, "text_length": txt_len, "detected": detected, "declared": declared})
PY

Repository: SafeFam/SafeFam_AI

Length of output: 390


Reproduce the same has_url signal in single-message prediction.

Training and batch inference pass df["has_url"] to extract_struct_feature_matrix, but single-message paths call extract_struct_features(text) with no has_url argument. This sends 0 for URL flags when no regex match is found, even if the corresponding dataset value is True, so the model can learn a signal that serving cannot reproduce. Pass the same declared has_url value in naive_bayes_analyzer.py and in predict_risk_score, or derive it identically during training.

📍 Affects 2 files
  • app/analysis/text/naive_bayes_analyzer.py#L116-L119 (this comment)
  • data_science/SMSModel/train_sms.py#L546-L546
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/analysis/text/naive_bayes_analyzer.py` around lines 116 - 119, The
single-message feature construction in naive_bayes_analyzer.py and
predict_risk_score does not reproduce the declared has_url training signal.
Update each extract_struct_features call to pass the same has_url value used by
extract_struct_feature_matrix, including the site at
data_science/SMSModel/train_sms.py:546, or consistently derive that value
identically during training and serving.


X_text = _vectorizer.transform([text_norm])
X = hstack([X_text, csr_matrix(struct)])
# 기존 artifact가 기대하는 입력 구조를 유지
text_features = _vectorizer.transform([normalized_text])
feature_matrix = hstack(
[
text_features,
csr_matrix(struct_features),
]
)

phishing_idx = _classes.index("phishing")
prob_phishing = _model.predict_proba(X)[0][phishing_idx]
risk_score = int(prob_phishing * 100)
phishing_index = _classes.index("phishing")
phishing_probability = _model.predict_proba(
feature_matrix
)[0][phishing_index]

logger.info(f"[NaiveBayes] 문자 분석 완료 - 위험도 점수: {risk_score}")
risk_score = int(phishing_probability * 100)

logger.info(
"[NaiveBayes] 문자 분석 완료 - 위험도 점수: %s",
risk_score,
)

return {
"engine": "naive_bayes",
"is_available": True,
"result": {
"grade": determine_text_risk_grade(risk_score),
"risk_score": risk_score,
"is_suspected_phishing": bool(prob_phishing >= _threshold),
"error_message": None
}
"is_suspected_phishing": bool(
phishing_probability >= _threshold
),
"error_message": None,
},
}
except Exception as exception:
logger.error(
"[NaiveBayes] 추론 중 비정상 오류 발생. error_type=%s",
type(exception).__name__,
)

return {
"engine": "naive_bayes",
"is_available": False,
"result": dict(DEFAULT_ANALYSIS_RESULT, error_message="Inference Error")
}
"result": dict(
DEFAULT_ANALYSIS_RESULT,
error_message="Inference Error",
),
}
Loading
Loading