평가 캐시와 개인정보 마스킹 보강

This commit is contained in:
Yun Chan 2026-06-28 20:12:20 +09:00
parent f0771db919
commit 6a81ec596c
11 changed files with 737 additions and 14 deletions

View file

@ -36,7 +36,79 @@ CRISIS_RESOURCE_MESSAGE = (
# ════════════════════════════════════════════════════════════════════════════
# 정규식 폴백 패턴 (Presidio 미설치 시). 한국 맥락 우선.
# TODO: Presidio + MedicalNERRecognizer 로 정밀화(이름/주소/기관 NER).
_KOREAN_SURNAME_CHARS = (
"김이박최정강조윤장임한오서신권황안송전홍유고문양손배백허남심노하"
"곽성차주우구민류나진지엄채원천방공현함변염여추도소석선설마길연위표"
"명기반왕금옥육인맹제모탁국어은편용예봉경"
)
_KOREAN_FULL_NAME = rf"[{_KOREAN_SURNAME_CHARS}][가-힣]{{1,3}}"
_KOREAN_NAME_STOPWORDS = {
"연락",
"이야기",
"생각",
"마음",
"기분",
"상담",
"학교",
"엄마",
"아빠",
"어머니",
"아버지",
"친구",
"내담자",
"상담자",
"선생님",
"소속",
"안내",
}
_PII_PATTERNS: list[tuple[str, re.Pattern[str]]] = [
# 한국어 기관/소속명: 학교·병원·센터·학과 등 명시 suffix가 있는 경우만 보수적으로 마스킹.
(
"ORG",
re.compile(
r"(?<![가-힣A-Za-z0-9])"
r"(?P<value>[가-힣A-Za-z0-9·&().-]{2,30}?"
r"(?:대학교|대학원|고등학교|중학교|초등학교|병원|의원|클리닉|상담센터|센터|복지관|교육청|보건소|연구소|재단|협회|학과|학부))"
r"(?P<suffix>\s*(?:입니다|이에요|예요|이고|이고요|에서|에|의|은|는|이|가|을|를)?)"
r"(?=$|[\s,.;!?。])"
),
),
# 한국어 이름: 이름/성명/실명 라벨 뒤 값.
(
"NAME",
re.compile(
r"(?P<prefix>(?:이름|성명|실명|본명)\s*[:]\s*)"
r"(?P<value>[가-힣]{2,4})"
r"(?=$|[\s,.;!?。])"
),
),
# 한국어 이름: 역할/관계 명사 뒤에 붙은 인명 + 조사/호칭.
(
"NAME",
re.compile(
r"(?P<prefix>(?:내담자|상담자|학생|보호자|담임|교수|선생님|친구|엄마|아빠|어머니|아버지|동생|언니|오빠|형|누나)\s+)"
rf"(?P<value>{_KOREAN_FULL_NAME})"
r"(?P<suffix>\s*(?:님|씨|학생|상담자|내담자)?"
r"(?:은|는|이|가|을|를|와|과|에게|한테|라고|이라는|입니다|이에요|예요|이고|이고요))"
),
),
# 한국어 이름: 성씨 기반 full-name + 조사. 문맥 없는 순수 2~4글자 마스킹은 오탐이 커서 피한다.
(
"NAME",
re.compile(
rf"(?<![가-힣])(?P<value>{_KOREAN_FULL_NAME})"
r"(?P<suffix>(?:은|는|이|가|을|를|와|과|에게|한테|라고|이라는))"
),
),
# 한국어 이름: "김서연 씨", "박민수님" 같은 명시 호칭.
(
"NAME",
re.compile(
rf"(?<![가-힣])(?P<value>{_KOREAN_FULL_NAME})"
r"(?P<suffix>\s?(?:씨|님)(?:은|는|이|가|을|를|와|과|에게|한테|고|이고|인데)?)"
r"(?=$|[\s,.;!?。])"
),
),
# 주민등록번호 (6자리-7자리)
("RRN", re.compile(r"\b\d{6}[-\s]?\d{7}\b")),
# 휴대폰 (010-1234-5678 등)
@ -53,7 +125,6 @@ _PII_PATTERNS: list[tuple[str, re.Pattern[str]]] = [
("MONEY", re.compile(r"\d{1,3}(?:,\d{3})+\s?원|\d{3,}\s?원")),
# 한국 주소 단편: ○○시/도 ○○시/군/구 ○○동/읍/면/로/길 (행정구역 연쇄)
("ADDR", re.compile(r"[가-힣]{2,}(?:시|도)\s?[가-힣]{1,4}(?:시|군|구)\s?[가-힣0-9]{1,}(?:동|읍|면|로|길)")),
# TODO(NER): 한국어 이름/기관명은 Presidio ko 모델/NER 필요(정규식 false-positive 위험).
]
# Presidio 지연 로드 캐시 (-1=미시도, None=미설치, 객체=설치됨)
@ -85,6 +156,30 @@ class MaskResult:
used_presidio: bool = False
def _mask_regex_pii(text: str) -> tuple[str, list[str]]:
masked = text
found: list[str] = []
def replace_match(label: str):
def _replace(match: re.Match[str]) -> str:
group = match.groupdict().get("value")
if group is None:
found.append(label)
return f"[{label}]"
if label == "NAME" and group in _KOREAN_NAME_STOPWORDS:
return match.group(0)
value_start = match.start("value") - match.start(0)
value_end = match.end("value") - match.start(0)
found.append(label)
return f"{match.group(0)[:value_start]}[{label}]{match.group(0)[value_end:]}"
return _replace
for label, pat in _PII_PATTERNS:
masked = pat.sub(replace_match(label), masked)
return masked, sorted(set(found))
def mask_pii(text: str) -> MaskResult:
"""PII 마스킹. Presidio 가용 시 우선, 아니면 정규식 폴백.
@ -99,18 +194,18 @@ def mask_pii(text: str) -> MaskResult:
results = analyzer.analyze(text=text, language="en") # TODO: ko 모델 등록 시 language="ko"
ents = sorted({r.entity_type for r in results})
anonymized = anonymizer.anonymize(text=text, analyzer_results=results)
return MaskResult(text_masked=anonymized.text, entities=ents, used_presidio=True)
masked, regex_ents = _mask_regex_pii(anonymized.text)
return MaskResult(
text_masked=masked,
entities=sorted(set(ents + regex_ents)),
used_presidio=True,
)
except Exception:
pass # 폴백으로
# 정규식 폴백
masked = text
found: list[str] = []
for label, pat in _PII_PATTERNS:
if pat.search(masked):
found.append(label)
masked = pat.sub(f"[{label}]", masked)
return MaskResult(text_masked=masked, entities=sorted(set(found)), used_presidio=False)
masked, found = _mask_regex_pii(text)
return MaskResult(text_masked=masked, entities=found, used_presidio=False)
# ════════════════════════════════════════════════════════════════════════════