Spaces:
Sleeping
Sleeping
| """한국어 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 | |