"""Phase 23b — 23의 결과 정제 개선사항: 1. 메타/리포트 노이즈 확장 필터 (px, Frame, TEXT, 타이틀, 서브헤더, 뱃지 등) 2. Kiwi 일반 어간/조동사 stopword 확장 (통하, 위하, 대하, 가능, 필요 등) 3. safe_normalization 후보를 승격가능/보류/폐기로 자동 분류 4. 약어 풀네임 regex 확장 (한영 혼합, 쉼표 구분, 대문자 접두어 조건 완화) 5. corpus 소스 개수 정확한 설명 산출: domain_terms.yaml (덮어쓰기) + DOMAIN_TERMS_REPORT.md 갱신 """ import sys import re import collections from pathlib import Path import yaml sys.path.insert(0, str(Path(__file__).parent)) from methods import _get_kiwi, _extract_content_tokens ROOT = Path(r"d:\ad-hoc\kei\design_agent") BLOCKS_DIR = ROOT / "figma_to_html_agent" / "blocks" MDX_DIR = ROOT / "samples" / "mdx_batch" HERE = Path(__file__).parent # ═══ 확장 노이즈 필터 ═══ _NOISE_PATTERNS = [ re.compile(r"^\d+$"), re.compile(r"^\d+[년월일%]"), re.compile(r"^\d{2}[\.\-/]\d{2}"), re.compile(r"^[①②③④⑤⑥⑦⑧⑨⑩]+$"), re.compile(r"^[가-힣]$"), re.compile(r"^[A-Za-z]$"), re.compile(r"^[IVXLCDM]+$"), # 로마숫자 re.compile(r"^\d+[a-zA-Z]$"), ] # 리포트/Figma/HTML 메타 용어 — 도메인 아님 _META_NOISE = { # Figma/MCP 추출 메타 "Frame", "frame", "TEXT", "Text", "text", "IMAGE", "image", "bbox", "nodeId", "node", "Vector", "Group", "Polygon", "Line", "fill", "stroke", "opacity", # 레이아웃/스타일 "px", "center", "top", "bottom", "left", "right", "middle", "padding", "margin", "border", "radius", # 리포트 구조 라벨 (analysis/texts.md에 반복) "타이틀", "서브타이틀", "서브헤더", "뱃지", "라벨", "섹션", "본문", "헤더", "행", "열", "카드", "박스", "영역", "패널", "컬럼", "텍스트", "아이콘", # 일반 HTML "src", "alt", "href", "html", "body", # analysis.md 내부 구조 "내용", "구조", "후보", "키워드", "메타", # 위치 기호 "좌측", "우측", "상단", "하단", } # Kiwi가 명사/어간으로 잡지만 사실 일반어 _GENERIC_STEM = { "통하", "위하", "대하", "따라", "의하", "가능", "필요", "다양", "많", "적", "크", "작", "높", "낮", "좋", "나쁘", "쉽", "어렵", "하", "되", "있", "없", "같", "다르", "대한", "위한", "통한", "따른", "의한", "것", "수", "등", "때", "중", "후", "전", "및", "또", "또한", "이", "그", "저", "위", "바", "뿐", "내용", "사용", "활용", "적용", "수행", "진행", "수용", "제공", "방법", "방식", "과정", "결과", "상태", # (context에 따라 도메인어지만 단독 일반) "관련", "대상", "종류", "종합", } def is_noise(token): if len(token) <= 1: return True if token in _META_NOISE: return True if token in _GENERIC_STEM: return True for p in _NOISE_PATTERNS: if p.match(token): return True return False # ═══ Corpus 로드 (정확한 소스 개수 세기) ═══ def load_corpus(): sources = {} # BEPS beps = BLOCKS_DIR / "1171281171" / "texts.md" if beps.exists(): sources["BEPS/texts.md"] = beps.read_text(encoding="utf-8") # Figma frames (32개 전체) — 32개 중 1171281171도 포함되는지 확인 필요 figma_frame_count = 0 figma_file_count = 0 for d in sorted(BLOCKS_DIR.iterdir()): if not d.is_dir(): continue fid = d.name if not fid.startswith("1171"): continue figma_frame_count += 1 if fid == "1171281171": # BEPS는 texts.md는 이미 BEPS key로 넣었으니 analysis/flat만 for fn in ["analysis.md", "flat.md"]: p = d / fn if p.exists(): sources[f"Figma/{fid}/{fn}"] = p.read_text(encoding="utf-8") figma_file_count += 1 # BEPS texts.md는 위에서 카운트됨 (Figma 로도 1개 추가) figma_file_count += 1 # texts.md else: for fn in ["texts.md", "analysis.md", "flat.md"]: p = d / fn if p.exists(): sources[f"Figma/{fid}/{fn}"] = p.read_text(encoding="utf-8") figma_file_count += 1 # MDX mdx_count = 0 for mdx in ["01.mdx", "02.mdx", "03.mdx"]: p = MDX_DIR / mdx if p.exists(): sources[f"MDX/{mdx}"] = p.read_text(encoding="utf-8") mdx_count += 1 return sources, figma_frame_count, figma_file_count, mdx_count # ═══ 용어 추출 ═══ def mine_terms(corpus): kiwi = _get_kiwi() counter = collections.Counter() term_sources = collections.defaultdict(set) for src_id, text in corpus.items(): tokens = _extract_content_tokens(text, kiwi) for t in tokens: if is_noise(t): continue counter[t] += 1 term_sources[t].add(src_id) return counter, term_sources # ═══ Safe normalization 분류 ═══ _META_STRUCTURE_WORDS = {"서브타이틀", "서브헤더", "타이틀", "헤더", "라벨", "뱃지"} def classify_safe_norm(a, b): """공백 변형 쌍을 승격가능/보류/폐기로 분류""" joined = a if " " not in a else b spaced = b if " " in b else a # 노이즈: 마침표/특수문자 포함 (Engn.S) if "." in joined or "." in spaced: return "discard", "토큰에 마침표 포함 — Kiwi 분해 노이즈" # 노이즈: 너무 짧거나 접미사 조각 (rdParty) if len(joined.replace(" ", "")) < 4: return "discard", "너무 짧음 — 의미 토큰 아님" if joined.startswith("rd") or joined.startswith("st") or joined.startswith("nd") or joined.startswith("th"): return "discard", "서수 접미사 조각 (rdParty 등)" # 보류: 문서 메타 구조어 if joined in _META_STRUCTURE_WORDS or spaced in _META_STRUCTURE_WORDS: return "hold", "문서 구조 메타 — synonyms에 넣지 말 것" # 보류: 고유 제품명/브랜드 (WatchBIM, BIGRoom, TwinHighway 등 CamelCase/PascalCase) if re.match(r"^[A-Z][a-z]+[A-Z]", joined): return "hold", "고유 제품명/브랜드 — 치환 위험" # 승격가능: 도메인 관련 한글 복합어 return "promote", "도메인 일반어 공백 변형" def find_spacing_variants(terms, corpus_text): pairs = [] seen = set() for t in terms: if " " in t: continue if len(t) < 4: continue for split_pos in range(1, len(t)): spaced = t[:split_pos] + " " + t[split_pos:] if spaced in corpus_text and (t, spaced) not in seen: pairs.append((t, spaced)) seen.add((t, spaced)); seen.add((spaced, t)) break return pairs # ═══ 약어 풀네임 regex 확장 ═══ def find_abbrev_expansion(corpus_text): """여러 패턴 지원: - `Full Name (ABBR)` — 영문 풀네임 + 대문자 약어 - `ABBR (Full Name)` — 역순 - `한글단어 (ABBR)` / `ABBR (한글단어)` — 한영 혼합 - `ABBR (Full Name, 한글단어)` — 쉼표 구분 병기 """ results = collections.Counter() # Pattern 1: Long English (SHORT) p1 = re.compile(r"([A-Za-z][A-Za-z\s\&\-]{4,50}[A-Za-z])\s*\(([A-Z]{2,8})\)") for m in p1.finditer(corpus_text): full, abbr = m.group(1).strip(), m.group(2).strip() if len(full.split()) >= 2: results[(abbr, full)] += 1 # Pattern 2: SHORT (Long English or Korean or both comma-separated) p2 = re.compile(r"([A-Z]{2,8})\s*\(([^()]{5,80})\)") for m in p2.finditer(corpus_text): abbr, inside = m.group(1).strip(), m.group(2).strip() # inside를 쉼표로 분할 parts = [p.strip() for p in inside.split(",")] for p in parts: if not p: continue # 영문 full if re.match(r"^[A-Za-z][A-Za-z\s\&\-]{3,}$", p) and len(p.split()) >= 2: results[(abbr, p)] += 1 # 한글 풀네임 elif re.match(r"^[가-힣][가-힣\s]{3,}[가-힣]$", p): results[(abbr, p)] += 1 # Pattern 3: 한글 (ABBR) p3 = re.compile(r"([가-힣][가-힣\s]{3,20}[가-힣])\s*\(([A-Z]{2,8})\)") for m in p3.finditer(corpus_text): full, abbr = m.group(1).strip(), m.group(2).strip() results[(abbr, full)] += 1 return results def main(): corpus, n_figma_frames, n_figma_files, n_mdx = load_corpus() all_text = "\n".join(corpus.values()) print(f"Corpus 소스 구성:") print(f" BEPS 본문: 1개") print(f" Figma 프레임: {n_figma_frames}개 → {n_figma_files}개 파일 (각 프레임 당 texts/analysis/flat.md)") print(f" MDX 본문: {n_mdx}개") print(f" 총 소스 개수: {len(corpus)}개") print() # 1. 반복 용어 (확장 노이즈 필터 적용) counter, term_sources = mine_terms(corpus) filtered = [(t, c) for t, c in counter.items() if c >= 2] filtered.sort(key=lambda x: -x[1]) print(f"노이즈 필터 후 2회+ 등장 용어: {len(filtered)}개") print(f"상위 20개: {[t for t, _ in filtered[:20]]}") print() # 2. 공백 변형 + 분류 spacing = find_spacing_variants([t for t, _ in filtered], all_text) print(f"공백 변형 쌍 원본: {len(spacing)}개") classified = {"promote": [], "hold": [], "discard": []} for a, b in spacing: verdict, reason = classify_safe_norm(a, b) classified[verdict].append({ "canonical": a.replace(" ", ""), "variants": sorted([a, b]), "reason": reason, }) for v in ("promote", "hold", "discard"): print(f" {v}: {len(classified[v])}개") for item in classified[v]: print(f" {item['variants']} — {item['reason']}") print() # 3. 약어 풀네임 (확장 regex) abbrev = find_abbrev_expansion(all_text) print(f"약어 풀네임 쌍 (확장 regex): {len(abbrev)}개") for (a, f), c in abbrev.most_common(20): print(f" '{a}' ≡ '{f}' ({c}회)") print() # 4. 한영 병기 (evidence only 태그) p_keng = re.compile(r"([가-힣][가-힣\s]{1,15}[가-힣])\s*\(([A-Za-z][A-Za-z\s]{1,30}[A-Za-z])\)") p_engk = re.compile(r"([A-Za-z][A-Za-z\s]{1,30}[A-Za-z])\s*\(([가-힣][가-힣\s]{1,15}[가-힣])\)") ke_pairs = [] for m in p_keng.finditer(all_text): ke_pairs.append((m.group(1).strip(), m.group(2).strip())) for m in p_engk.finditer(all_text): ke_pairs.append((m.group(2).strip(), m.group(1).strip())) ke_counter = collections.Counter(ke_pairs) print(f"한영 병기 쌍: {len(ke_counter)}개 (대부분 evidence-only)") print() # 5. domain_terms.yaml 갱신 domain = { "meta": { "phase": "23b", "purpose": "검수용 도메인 용어 카탈로그 (synonyms.yaml/hierarchy.yaml 승격 전 단계)", "corpus_breakdown": { "BEPS": "1 (본문 source)", "Figma_frames": f"{n_figma_frames}개 프레임 → {n_figma_files}개 파일 (texts/analysis/flat)", "MDX": f"{n_mdx}개", "total_sources": len(corpus), }, "total_terms_2plus_after_noise_filter": len(filtered), "note": "synonyms.yaml은 현재 6개 canonical로 동결. 이 파일은 승격 후보만 제공.", }, "top_terms_by_frequency": [ {"term": t, "count": c, "sources": len(term_sources[t])} for t, c in filtered[:50] ], "safe_normalization": { "promote": classified["promote"], "hold": classified["hold"], "discard": classified["discard"], }, "abbrev_fullname_candidates": [ {"abbr": a, "full": f, "count": c} for (a, f), c in abbrev.most_common(40) ], "ko_en_paren_pairs_evidence_only": [ {"ko": k, "en": e, "count": c} for (k, e), c in ke_counter.most_common(40) ], "needs_human_review": [ "발주자 vs 발주처 (역할 vs 기관)", "시공자 vs 시공사 (역할 vs 회사)", "설계자 vs 설계사 (역할 vs 회사)", "Engineering S/W vs Engn. S/W (약어 — 동의어 가능)", "Engineering S/W vs Solution S/W (상하위 or 분류)", "Engineering S/W vs Application S/W (상하위 or 분류)", ], } out = HERE / "domain_terms.yaml" with open(out, "w", encoding="utf-8") as f: yaml.safe_dump(domain, f, allow_unicode=True, sort_keys=False, width=120) print(f"완료: {out}") if __name__ == "__main__": main()