Talk-Trace-AI / validators.py
Claude
fix: 코드 리뷰 지적사항 8건 수정
44ebedd unverified
Raw History Blame Contribute Delete
9.16 kB
import re
# ══════════════════════════════════════════════
# [1] 에러 코드 상수
# ══════════════════════════════════════════════
class ValidationError:
EMPTY = "VALIDATION_EMPTY"
TOO_LONG = "VALIDATION_TOO_LONG"
INVALID_CHARS = "VALIDATION_INVALID_CHARS"
FORBIDDEN = "VALIDATION_FORBIDDEN"
# ── 에러 코드 → 사용자 메시지 매핑 ──
VALIDATION_MESSAGES = {
ValidationError.EMPTY: "{field}을(를) 입력해 주세요.",
ValidationError.TOO_LONG: "{field}은(는) {max_len}자 이하여야 합니다.",
ValidationError.INVALID_CHARS: "{field}에 허용되지 않은 문자가 포함되어 있습니다.",
ValidationError.FORBIDDEN: "{field}에 사용할 수 없는 단어가 포함되어 있습니다.",
}
# ══════════════════════════════════════════════
# [2] 텍스트 정규화 유틸리티
# ══════════════════════════════════════════════
def normalize_user_text(raw_text, max_len=500):
"""앞뒤 공백 제거 후 max_len까지 자릅니다."""
text = (raw_text or "").strip()
return text[:max_len] if text else ""
def normalize_room_name(raw_text, max_len=60):
"""방 이름 전용: 연속 공백을 단일 공백으로 압축합니다."""
text = normalize_user_text(raw_text, max_len=max_len)
return re.sub(r"\s+", " ", text).strip() if text else ""
# ══════════════════════════════════════════════
# [3] IP 마스킹
# ══════════════════════════════════════════════
def mask_ip_for_teacher(ip_text):
"""
교사 화면에 표시할 IP를 마스킹합니다.
IPv4 예: 1.XXX.XXX.4
IPv6 예: 2001:db8:XXXX:XXXX:1
"""
ip = str(ip_text or "").strip()
if not ip:
return ""
ipv4_parts = ip.split(".")
if len(ipv4_parts) == 4 and all(part.isdigit() for part in ipv4_parts):
return f"{ipv4_parts[0]}.XXX.XXX.{ipv4_parts[3]}"
if ":" in ip:
ipv6_parts = ip.split(":")
if len(ipv6_parts) >= 3:
return f"{ipv6_parts[0]}:{ipv6_parts[1]}:XXXX:XXXX:{ipv6_parts[-1]}"
return ip
# ══════════════════════════════════════════════
# [4] author_role fallback
# ══════════════════════════════════════════════
def with_fallback_author_role(df):
"""
author_role 컬럼이 없거나 비어 있는 레거시 데이터를
student_name 기반으로 역할을 추정해 채웁니다.
- '교사' / '선생님' 포함 → '교사'
- 그 외 → '학생'
"""
if df.empty:
return df
fixed = df.copy()
if "author_role" not in fixed.columns:
fixed["author_role"] = "학생"
return fixed
fixed["author_role"] = fixed["author_role"].fillna("").astype(str).str.strip()
teacher_name_hint = fixed["student_name"].fillna("").astype(str).str.contains(
"교사|선생님", regex=True
)
fixed.loc[(fixed["author_role"] == "") & teacher_name_hint, "author_role"] = "교사"
fixed.loc[fixed["author_role"] == "", "author_role"] = "학생"
return fixed
# ══════════════════════════════════════════════
# [5] 허용 문자 패턴
# ══════════════════════════════════════════════
# 일반 텍스트 필드: 한글, 영문, 숫자, 기본 특수문자 허용
ALLOWED_TEXT_PATTERN = re.compile(
r"^[0-9A-Za-z가-힣ㄱ-ㅎㅏ-ㅣ \_.,!?():;@#&/\-]*$"
)
# 입장 암호: 일반 텍스트보다 넓은 특수문자 허용
ALLOWED_ENTRY_CODE_PATTERN = re.compile(
r"^[0-9A-Za-z가-힣ㄱ-ㅎㅏ-ㅣ \_!@#$%^&*()+=.?/:;\-]*$"
)
# 교사 ID/PW: 영문·숫자·일부 특수문자만 허용 (공백 불허)
ALLOWED_CREDENTIAL_PATTERN = re.compile(
r"^[0-9A-Za-z\_.@\-]+$"
)
# ══════════════════════════════════════════════
# [6] 금지어 검사
# ══════════════════════════════════════════════
def _contains_forbidden_word(text, forbidden_words=None):
"""forbidden_words 중 하나라도 포함되면 True를 반환합니다."""
words = forbidden_words or []
lowered = str(text or "").lower()
return any(str(word).lower() in lowered for word in words)
# ══════════════════════════════════════════════
# [7] 공통 검증 함수
# ══════════════════════════════════════════════
def _validate_text_field(
raw_text,
*,
field_name,
max_len,
allow_empty=False,
forbidden_words=None,
allowed_pattern=ALLOWED_TEXT_PATTERN,
):
"""
텍스트 필드 공통 검증 로직.
Returns
-------
(ok: bool, safe_text: str, error_code: str | None, error_message: str | None)
"""
stripped = (raw_text or "").strip()
if not stripped:
if allow_empty:
return True, "", None, None
return (
False, "",
ValidationError.EMPTY,
VALIDATION_MESSAGES[ValidationError.EMPTY].format(field=field_name),
)
if len(stripped) > max_len:
return (
False, "",
ValidationError.TOO_LONG,
VALIDATION_MESSAGES[ValidationError.TOO_LONG].format(
field=field_name, max_len=max_len
),
)
normalized = stripped
if allowed_pattern and not allowed_pattern.match(normalized):
return (
False, "",
ValidationError.INVALID_CHARS,
VALIDATION_MESSAGES[ValidationError.INVALID_CHARS].format(field=field_name),
)
if _contains_forbidden_word(normalized, forbidden_words=forbidden_words):
return (
False, "",
ValidationError.FORBIDDEN,
VALIDATION_MESSAGES[ValidationError.FORBIDDEN].format(field=field_name),
)
return True, normalized, None, None
# ══════════════════════════════════════════════
# [8] 필드별 검증 함수
# ══════════════════════════════════════════════
def validate_room_name(raw_text, max_len=60):
"""
방 이름 검증.
- 연속 공백 압축 후 검사
- '관리자', '운영자' 금지
"""
collapsed = normalize_room_name(raw_text, max_len=max_len)
return _validate_text_field(
collapsed,
field_name="방 이름",
max_len=max_len,
allow_empty=False,
forbidden_words=["관리자", "운영자"],
)
def validate_student_name(raw_text, max_len=30):
"""
학생 이름/학번 검증.
- '관리자', '운영자', 'admin' 금지
"""
return _validate_text_field(
raw_text,
field_name="학생 이름/학번",
max_len=max_len,
allow_empty=False,
forbidden_words=["관리자", "운영자", "admin"],
)
def validate_opinion_content(raw_text, max_len=700):
"""
의견 내용 검증.
- 최대 700자
- 길이만 체크 (특수문자·이모지 허용)
"""
return _validate_text_field(
raw_text,
field_name="의견 내용",
max_len=max_len,
allow_empty=False,
allowed_pattern=None,
)
def validate_entry_code(raw_text, max_len=60):
"""
방 입장 암호 검증.
- 비어 있어도 허용 (공개방)
- 일반 텍스트보다 넓은 특수문자 허용
"""
return _validate_text_field(
raw_text,
field_name="입장 암호",
max_len=max_len,
allow_empty=True,
allowed_pattern=ALLOWED_ENTRY_CODE_PATTERN,
)
def validate_teacher_credential(raw_text, *, field_name, max_len=60):
"""
교사 ID / PW 검증.
- 공백 불허
- 영문, 숫자, _.@- 만 허용
"""
return _validate_text_field(
raw_text,
field_name=field_name,
max_len=max_len,
allow_empty=False,
forbidden_words=[" "],
allowed_pattern=ALLOWED_CREDENTIAL_PATTERN,
)