"""공통 키워드 정규화 모듈. Step 4, 5, 7 등 여러 파이프라인이 공유하는 정규화 로직을 한 파일로 집중. 이 모듈은 **AI 호출을 수행하지 않음** — 순수 결정론적 규칙만. 제공 기능: - PRE_COLLAPSE regex (디지털 전환(DX) 계열 collapse) - 괄호 확장 (A(B)C → AC + B) - phrase_variants 치환 - Kiwi user_dict 토큰화 - 허용 태그 필터 (NNG/NNP/SL/SN + 1글자/순수숫자 제외) 사용 pipeline: - pipeline_04_normalize.py (정규화 + 저장) - pipeline_07_auto_anchor_candidates.py (source_text 기반 후보) (미래) 기타 step 설계 제약: - 상수/함수는 재진입 가능해야 함 (counter 를 None 허용) - Kiwi 객체는 build_kiwi() 로 생성 (global state 금지) - 모든 함수는 deterministic (random seed 없음) """ import re from collections import Counter from pathlib import Path import yaml from kiwipiepy import Kiwi # ============================================================ # 상수 # ============================================================ # Kiwi user_dict — compound (SL) USER_DICT_SL = ['S/W', 'H/W', '2D', '3D', 'DX', 'BIM', 'As-is', 'To-Be'] # Kiwi user_dict — phrase canonical (NNG) USER_DICT_NNG = ['결과혁신', '과정혁신', '필수조건', '의사소통', '시행착오'] # Kiwi 허용 태그 ALLOWED_TAGS = {'NNG', 'NNP', 'SL', 'SN'} # PRE_COLLAPSE: "디지털 전환(DX...)" / "DX(디지털전환)" / "DX(Digital Transformation)" / "DX(DX...)" → DX # 순서 중요: 긴 → 짧은, 원본 → 치환 부산물. PRE_COLLAPSE = [ ( re.compile( r'디지털\s*전환\s*\(\s*DX(?:\s*[,/]\s*Digital\s+Transformation)?\s*\)', re.IGNORECASE, ), 'DX', 'digital_transition_with_dx', ), ( re.compile(r'DX\s*\(\s*디지털\s*전환\s*\)', re.IGNORECASE), 'DX', 'dx_with_digital_transition', ), ( re.compile(r'DX\s*\(\s*Digital\s+Transformation\s*\)', re.IGNORECASE), 'DX', 'dx_with_english_fullname', ), ( re.compile( r'DX\s*\(\s*DX(?:\s*[,/]\s*Digital\s+Transformation)?\s*\)', re.IGNORECASE, ), 'DX', 'dx_duplicate', ), # 5. SW (단독 word) → S/W — 정규화 누락 보정 (word boundary 중요: SWOT 등 보호) ( re.compile(r'(? MAX_PAREN_LEN 은 펼치지 않음 - 숫자/기호-only 괄호 내용 제외 ((1), (※) 등) paren_counter / paren_samples 는 None 허용 — 카운팅 안 할 때는 None. """ contents = PAREN_PATTERN.findall(text) if not contents: return text meaningful = [] for c in contents: stripped = c.strip() if not stripped: continue if PAREN_SKIP_PATTERN.fullmatch(stripped): continue meaningful.append(stripped) if not meaningful: return text before = text text_out = PAREN_PATTERN.sub('', text) text_out = re.sub(r'\s+', ' ', text_out).strip() for c in meaningful: text_out = text_out + ' ' + c if paren_counter is not None: paren_counter['expansions'] += 1 if paren_samples is not None and before != text_out and len(paren_samples) < PAREN_SAMPLE_MAX: paren_samples.append({'before': before, 'after': text_out}) return text_out def apply_substitutions(text, subs, replacement_counter, pre_collapse_counter, paren_counter=None, paren_samples=None): """PRE_COLLAPSE → 괄호 확장 → phrase_variants 순서 적용.""" # 1) PRE_COLLAPSE (regex) for pattern, replacement, rule_id in PRE_COLLAPSE: new_text, n = pattern.subn(replacement, text) if n > 0: if pre_collapse_counter is not None: pre_collapse_counter[rule_id] += n text = new_text # 1.5) 괄호 확장 text = expand_parentheses(text, paren_counter, paren_samples) # 2) phrase_variants (literal) for variant, canonical in subs: new_text, n = re.subn(re.escape(variant), canonical, text) if n > 0: if replacement_counter is not None: replacement_counter[canonical] += n text = new_text return text def build_kiwi(): """user_dict 포함 Kiwi 객체 생성.""" kiwi = Kiwi() for w in USER_DICT_SL: kiwi.add_user_word(w, 'SL', 9.0) for w in USER_DICT_NNG: kiwi.add_user_word(w, 'NNG', 9.0) return kiwi def extract_tokens(kiwi, text, removal_log=None): """Kiwi tokenize + 허용 태그/1글자/순수숫자 필터 + 사용자 확정 필터 규칙. removal_log: dict[str, list[str]] 또는 None — 제거된 토큰 + 이유 기록 (추적용). 예: {'interrogative': ['How', 'WHEN'], 'stopword': ['the', 'to'], ...} """ out = [] for tok in kiwi.tokenize(text): if tok.tag not in ALLOWED_TAGS: continue f = tok.form if len(f) < 2: continue if re.fullmatch(r'\d+', f): continue # 사용자 확정 필터 (stopword / number / short_lowercase / interrogative) remove, reason = should_filter_token(f) if remove: if removal_log is not None: removal_log.setdefault(reason, []).append(f) continue out.append(f) return out def normalize_and_tokenize(text, subs, kiwi, replacement_counter=None, pre_collapse_counter=None, paren_counter=None, paren_samples=None): """text → (normalized_text, tokens). 복합 파이프라인 단일 진입점.""" normalized = apply_substitutions( text, subs, replacement_counter, pre_collapse_counter, paren_counter, paren_samples, ) tokens = extract_tokens(kiwi, normalized) return normalized, tokens