hetest / ner_filter.py
jkkim
🇯🇵 일본어 PII + APPI/マイナンバー法 컴플라이언스 트랙 추가
ee1da18
Raw
History Blame Contribute Delete
11.6 kB
"""한국어 NER 후처리 휴리스틱 필터 (A안).
문제 인식
---------
spaCy 한국어 NER 가 회의록·기술 문서에서 PERSON / LOCATION 을 마구 잡아
조사·어미·동사·한자어가 모두 인명/지명으로 잘못 잡히는 사례가 많다.
---
- PERSON : 2~4자, 순수 한글, 흔한 한국 성씨로 시작, 조사/어미로 끝나면 거부
- LOCATION : 길이 ≤ 8자, 순수 한글, 조사/어미 거부
- ORGANIZATION : 길이 ≤ 12, 조사/어미 거부 (다국어 회사명도 허용)
대상
-----
**spaCy NER 출처(`SpacyRecognizer`) 만 필터링.** custom_patterns / regex /
deny-list / KoELECTRA-NER 결과는 신뢰도가 충분해 그대로 통과.
"""
from __future__ import annotations
# 한국어에서 매치 끝에 붙으면 진짜 인명/지명일 가능성이 매우 낮은 토큰들
_REJECT_SUFFIX = (
# 조사
"이", "가", "은", "는", "을", "를", "에", "의", "도", "만", "까지",
"에서", "으로", "로", "에게", "한테", "와", "과", "이나", "나", "랑",
"이며", "며", "이고", "고", "으로서", "로서", "으로써", "로써", "처럼",
"같이", "보다", "마다", "조차", "마저",
# 동사·형용사 어미
"다", "요", "면", "서", "고", "지만", "거나", "든지", "어서", "아서",
"는데", "은데", "ㄴ데", "겠다", "었다", "았다", "하기", "되기", "이기",
"하게", "되게", "하지", "되지", "함", "됨", "임",
"기로", "도록", "려고", "ㄹ려고", "려는", "랴고", "ㄹ까", "ㄹ지",
"으면", "면서", "으면서", "려면", "ㄹ려면", "고서",
)
# 매치 시작이면 PERSON 가능성 낮은 단어 (지시·대명·접속·일반명사 시작)
_REJECT_PREFIX = (
"여기", "거기", "저기", "이것", "그것", "저것",
"오늘", "어제", "내일", "올해", "작년", "다음", "이번", "지난",
"최종", "기본", "사업", "산업", "기술", "정부", "회사", "고객",
"준비", "수정", "확인", "제출", "발표", "회의", "행사", "공무원",
"매뉴얼", "자료", "회의록", "성과", "일정",
"받을", "넣을", "쓸", "할", "갈", "올",
"안녕", "감사", "환영",
# 추가 — 부서·업무·평가 어휘 (회의록·검토 문서에 흔함)
"평가", "검토", "심사", "관리", "운영", "총무", "기획", "전략",
"영업", "마케팅", "구매", "인사", "교육", "연구", "개발", "생산",
"품질", "물류", "재무", "회계", "법무", "감사", "보안", "안전",
"건의", "보고", "제안", "검사", "조치", "결과", "활동", "처리",
"참고", "참석", "참여", "협의", "공유", "지원", "요청", "수신",
"송신", "전달", "교부", "발송", "수령", "접수", "처분", "통보",
)
# 흔한 한국 성씨 (상위 ~30개로 인구 80% 커버)
_COMMON_SURNAMES = set(
"김이박최정강조윤장임"
"한오서신권황안송류전"
"홍고문양손배백허남심노"
)
# 한글 음절 범위 (가-힣)
def _is_pure_hangul(s: str) -> bool:
return bool(s) and all("가" <= c <= "힯" for c in s)
def _has_special(s: str) -> bool:
return any(c in " \t\n\r.,!?;:()[]{}\"'`~@#$%^&*+=|\\/<>" for c in s)
def _is_likely_kr_person(text: str) -> tuple[bool, str | None]:
"""반환: (통과 여부, 거부 이유)"""
if not text or len(text) < 2:
return False, "too_short"
if len(text) > 4:
return False, "too_long_for_person"
if _has_special(text):
return False, "contains_special_chars"
if not _is_pure_hangul(text):
return False, "non_hangul"
if text[0] not in _COMMON_SURNAMES:
return False, "uncommon_surname"
if text.startswith(_REJECT_PREFIX):
return False, "prefix_blacklist"
if text.endswith(_REJECT_SUFFIX):
return False, "suffix_blacklist"
return True, None
# LOCATION 은 보통 행정 단위 접미사로 끝남 (긍정 신호)
_LOC_ADMIN_SUFFIX = (
"시", "도", "구", "군", "동", "리", "읍", "면", "가",
"로", "길", "역", "공항", "항구", "원", "공원",
)
# LOCATION 으로 흔히 잘못 잡히는 일반 명사
_LOC_BLACKLIST = {
"여기", "거기", "저기", "이곳", "그곳", "저곳",
"본사", "지사", "본부", "지점", "사무실", "회의실",
"현장", "내부", "외부", "전국", "전체", "각종",
}
def _is_likely_kr_location(text: str) -> tuple[bool, str | None]:
if not text or len(text) < 2:
return False, "too_short"
if len(text) > 8:
return False, "too_long_for_location"
if _has_special(text):
return False, "contains_special_chars"
if not _is_pure_hangul(text):
return False, "non_hangul"
if text in _LOC_BLACKLIST:
return False, "location_blacklist"
if text.startswith(_REJECT_PREFIX):
return False, "prefix_blacklist"
if text.endswith(_REJECT_SUFFIX):
return False, "suffix_blacklist"
# 행정 단위 접미사가 있으면 강한 신호 — 통과
if text.endswith(_LOC_ADMIN_SUFFIX):
return True, None
# 단일 단어 (2~3자) 인데 행정 접미사 없으면 의심 — 일반 한국 지명 (예: 부산) 만 허용
# PoC: 2자는 통과, 3자 이상은 행정 단위 필요
if len(text) <= 2:
return True, None
return False, "no_admin_suffix"
def _is_likely_org(text: str) -> tuple[bool, str | None]:
if not text or len(text) < 2:
return False, "too_short"
if len(text) > 14:
return False, "too_long_for_org"
if _has_special(text.replace(" ", "")): # 회사명에 띄어쓰기 허용
return False, "contains_special_chars"
if text.endswith(_REJECT_SUFFIX):
return False, "suffix_blacklist"
return True, None
# ---------------------------------------------------------------------------
# 일본어 휴리스틱 — APPI 대응. 조사/접미 + 都道府県/市区町村 신호
# ---------------------------------------------------------------------------
_JP_REJECT_SUFFIX = (
# 조사 (助詞)
"は", "が", "を", "に", "へ", "で", "と", "の", "も", "や", "か",
"から", "まで", "より", "など", "ばかり", "だけ", "しか", "こそ",
# 동사·형용사 어미 (用言活用)
"です", "ます", "した", "する", "ました", "ません", "だった",
"ている", "ています", "なる", "なって", "ない", "ある",
)
_JP_REJECT_PREFIX = (
# 대명·지시 + 일반 명사 (PERSON 오탐 빈출)
"これ", "それ", "あれ", "ここ", "そこ", "あそこ", "どこ",
"今日", "明日", "昨日", "今年", "去年", "来年",
"会社", "部署", "営業", "経理", "総務", "人事", "企画",
"確認", "提出", "発表", "会議", "議事録", "資料", "報告",
"管理", "運営", "検査", "検討", "評価", "実施", "対応",
)
# 일본어 행정구역 접미사 (긍정 신호 — LOCATION 통과)
_JP_LOC_ADMIN_SUFFIX = (
"県", "府", "都", "道", "市", "区", "町", "村", "郡",
"丁目", "番地", "号", "駅", "空港", "港",
)
_JP_LOC_BLACKLIST = {
"ここ", "そこ", "あそこ", "どこ",
"本社", "支社", "本部", "支店", "事務所", "会議室",
"現場", "内部", "外部", "全国", "全体",
}
# ひらがな (U+3040-309F) · カタカナ (U+30A0-30FF) · CJK (U+4E00-9FFF)
def _is_hiragana(c: str) -> bool:
return "぀" <= c <= "ゟ"
def _is_katakana(c: str) -> bool:
return "゠" <= c <= "ヿ"
def _is_kanji(c: str) -> bool:
return "一" <= c <= "鿿"
def _is_japanese_text(s: str) -> bool:
"""가나 (히라가나·카타카나) 출현 → 명백한 일본어.
한글 부재 + CJK 한자 포함 → 일본어 (현대 한국어는 한자만 쓰는 경우 드물고
spaCy ja NER 결과는 대부분 한자 위주이므로 안전한 기본값)."""
if not s:
return False
has_kana = any(_is_hiragana(c) or _is_katakana(c) for c in s)
if has_kana:
return True
has_hangul = any("가" <= c <= "힯" for c in s)
has_kanji = any(_is_kanji(c) for c in s)
return has_kanji and not has_hangul
def _is_likely_jp_person(text: str) -> tuple[bool, str | None]:
if not text or len(text) < 2:
return False, "too_short"
if len(text) > 8: # 일본어 인명 — 姓 + 名 (공백 포함) 최대 8자
return False, "too_long_for_person"
if text.startswith(_JP_REJECT_PREFIX):
return False, "prefix_blacklist_jp"
if text.endswith(_JP_REJECT_SUFFIX):
return False, "suffix_blacklist_jp"
# 한자 또는 카타카나 위주 (히라가나 단독 = 어휘/조사 가능성 높음)
if all(_is_hiragana(c) or c.isspace() for c in text):
return False, "all_hiragana"
return True, None
def _is_likely_jp_location(text: str) -> tuple[bool, str | None]:
if not text or len(text) < 2:
return False, "too_short"
if len(text) > 16:
return False, "too_long_for_location"
if text in _JP_LOC_BLACKLIST:
return False, "location_blacklist_jp"
if text.endswith(_JP_REJECT_SUFFIX):
return False, "suffix_blacklist_jp"
# 행정구역 접미사 명시 → 강한 통과
if any(text.endswith(suf) for suf in _JP_LOC_ADMIN_SUFFIX):
return True, None
# 한자 2~4자 단독 (예: 渋谷, 東京) 은 일반 지명 가능성 — 통과
if 2 <= len(text) <= 4 and all(_is_kanji(c) for c in text):
return True, None
return False, "no_admin_suffix_jp"
def _is_likely_jp_org(text: str) -> tuple[bool, str | None]:
if not text or len(text) < 2:
return False, "too_short"
if len(text) > 20:
return False, "too_long_for_org"
if text.endswith(_JP_REJECT_SUFFIX):
return False, "suffix_blacklist_jp"
# 株式会社/合同会社 등 명시 → 강한 통과
if any(k in text for k in ("株式会社", "合同会社", "有限会社", "社団法人", "財団法人", "Inc", "Corp", "Ltd")):
return True, None
return True, None
def filter_findings(findings: list[dict]) -> tuple[list[dict], list[dict]]:
"""spaCy 출처 PERSON/LOCATION/ORG 만 검사 → (통과, 거부).
finding 의 텍스트가 일본어면 일본어 룰, 아니면 한국어 룰 적용."""
kept: list[dict] = []
dropped: list[dict] = []
for f in findings or []:
et = f.get("entity_type")
text = (f.get("text") or "").strip()
rec = (f.get("recognizer") or "")
# spaCy 출처가 아니면 그대로 통과
is_spacy = rec.startswith("Spacy") or "SpacyRecognizer" in rec
if not is_spacy or et not in ("PERSON", "LOCATION", "ORGANIZATION"):
kept.append(f)
continue
# 언어별 룰 선택 — 가나 포함이면 일본어 룰
is_jp = _is_japanese_text(text)
if et == "PERSON":
ok, reason = _is_likely_jp_person(text) if is_jp else _is_likely_kr_person(text)
elif et == "LOCATION":
ok, reason = _is_likely_jp_location(text) if is_jp else _is_likely_kr_location(text)
else:
ok, reason = _is_likely_jp_org(text) if is_jp else _is_likely_org(text)
if ok:
kept.append(f)
else:
dropped.append({**f, "filter_reason": reason})
return kept, dropped
def stats(findings: list[dict]) -> dict:
"""간단 카운트 — entity_type 별."""
out: dict = {}
for f in findings or []:
et = f.get("entity_type", "?")
out[et] = out.get(et, 0) + 1
return out