"""Milestone 1 / Step 1-5: keyword_base 초안 빌더 입력 corpus: - BEPS 마스터: figma_to_html_agent/blocks/1171281171/texts.md - 32 Figma frames: figma_to_html_agent/blocks/{fid}/texts.md - 3 MDX: samples/mdx_batch/0{1,2,3}.mdx - 기존 마이닝: tests/matching/domain_terms.yaml - 현 anchor pool: tests/matching/structure_ontology.yaml (templates_v1) 처리: 1) templates_v1 anchor_sets 에서 317 고유 term 추출 2) 각 term 에 대해 evidence 대조: - own_frame: 해당 frame own texts.md 에 exact substring - other_frames: 다른 frame texts.md 에 substring - beps: 1171281171 texts.md 에 substring - mdx: 3 MDX 어디엔가 substring 3) domain_terms.yaml 로부터 표기 변형 후보 추론 - safe_normalization.promote / abbrev_fullname / ko_en_paren 4) 분류 규칙 적용 → SAFE / MEDIUM / RISKY / REMOVE 5) KEYWORD_BASE_DRAFT.md 출력 분류 규칙: - STRUCTURE_PATTERNS 일치 → REMOVE (structure) - WEAK_ROW_EXACT 일치 → REMOVE (weak_row) - evidence 없음 (모든 corpus 에서 미등장) → REMOVE (no_evidence) - evidence 있고 표기 변형 후보 없음 → SAFE (promote as canonical alone) - evidence 있고 formatting 변형만 (공백 유무 등) → SAFE (with variants) - evidence 있고 abbrev-fullname pair 있음 → MEDIUM (review — abbrev 승격 판단) - evidence 있고 애매한 대체 관계 → RISKY (역할/기관 차이 등) """ import re import sys from pathlib import Path import yaml sys.path.insert(0, str(Path(__file__).parent)) 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 BEPS_ID = "1171281171" FRAME_IDS = sorted([ d.name for d in BLOCKS_DIR.iterdir() if d.is_dir() and d.name.startswith("1171") and d.name != BEPS_ID ]) # ─── 분류 규칙 (structure / weak) ──────────────────────── STRUCTURE_PATTERNS = [ r'^\d+열\w*$', r'^\d+사분면$', r'^\d+섹션$', r'^\d+강조$', r'^\d+카테고리$', r'^\d+카드$', r'^\d+col-.*$', r'^cards-\d+.*$', ] STRUCTURE_EXACT = { '표', '카드', '다이어그램', '맵', 'split-panel', 'paired', 'central-split', 'paired-rows', '순환도', '교차다이어그램', '관계도', '4각관계도', '상단배너', '풀페이지시각화', '위치기반시각화', '주체별관계', 'Rome', '인용구', '3요소', '5요소', # cardinality 서술 (사용자 확인 — 제거 방향) 'S/W로고', '3rdParty', # 3rdParty 는 제품 카테고리 라벨 } WEAK_ROW_EXACT = { '정의', '특징', '개요', '구성', '결론', '장점', '단점', '고객', '적극', '구체', '소극', # 사용자 명시 weak } # AI curator 가 붙인 summary label (Group A) — anchor 에서 제거 SUMMARY_LABEL_EXACT = { '3산업', '4대문제', '5대목표', '5목표', '6요소', 'BIM목적', 'BIM수행실정', '건설산업목표', '산업비교', '용어비교', '민원분석', '사업맵', # Frame 08 What/How/When 에서 파생된 요약 ('When' 은 Group C remove 와 동일 맥락) '어떻게관리', '언제활용', } # Group B: corpus 변형 못 잡았지만 실제 compound (canonical 유지) GROUP_B_KEEP = { 'Model특화', 'PC활용', 'Position기반', 'S/W솔루션', '공사비절감', '기술형식', '도구인식', '시공리스크', '시공전모델', '역량목표', '토목실무', '토지현황', '프로세스능력', '추론기반AI', '코딩기반SW', '설계Data', # 경계 case — 일단 keep } # Group C: 개별 판단 필요 — 제거 후보 SUMMARY_REMOVE_CANDIDATES = { 'Speed', 'When', # 영어 기능어 — 매칭 anchor 로 약함 '관점별', # row/structure 설명어에 가까움 } # Group C: 개별 판단 필요 — 유지 후보 (variants 없어도 프레임 주제 잘 잡음) SUMMARY_KEEP_CANDIDATES = { 'EngineeringSolution', 'CAD확장판', '자체기술력', '전문가중심', '지속투자', '직관성', '프레임워크', '필수성', } # ─── 정규화 변형 감지 ──────────────────────────────── def _normalize(s): """공격적 정규화: 공백/슬래시/하이픈/점/밑줄 제거 + 소문자.""" return re.sub(r'[\s/\-_.·]+', '', s).lower() def variant_candidates(term, corpus_text, corpus_normalized_cache=None): """표기 변형 후보 탐지. 다중 패턴. 전략: 1) 공백 삽입/제거 (숫자/문자/한영 경계, CamelCase) 2) 슬래시 삽입 (XX SW / X/W) 3) 공격 정규화 매칭: term 을 normalize 한 것과 같은 정규화 형태의 corpus 단어 탐색 """ candidates = set() # 1) 공백 변형 if ' ' in term: compact = term.replace(' ', '') if compact != term and compact in corpus_text: candidates.add(compact) else: boundaries = set() for m in re.finditer(r'(\d)([A-Za-z가-힣])', term): boundaries.add(m.start(2)) for m in re.finditer(r'([A-Za-z])([가-힣])', term): boundaries.add(m.start(2)) for m in re.finditer(r'([가-힣])([A-Za-z])', term): boundaries.add(m.start(2)) for m in re.finditer(r'([a-z])([A-Z])', term): boundaries.add(m.start(2)) for b in boundaries: v = term[:b] + ' ' + term[b:] if v != term and v in corpus_text: candidates.add(v) # CamelCase 다중 split 시도: DesignForManufacture → Design For Manufacture, Design for Manufacture if re.match(r'^[A-Z][a-z]+([A-Z][a-z]+)+$', term): spaced = re.sub(r'([a-z])([A-Z])', r'\1 \2', term) if spaced in corpus_text: candidates.add(spaced) spaced_lower_conn = re.sub(r' (For|And|Of|The|A|An) ', lambda m: f' {m.group(1).lower()} ', spaced) if spaced_lower_conn != spaced and spaced_lower_conn in corpus_text: candidates.add(spaced_lower_conn) # 한-한 compound split: 모든 Korean-Korean 경계에서 공백 삽입 시도 # "공사비절감" (5) → "공사비 절감", "시공전모델" (5) → "시공 전모델" / "시공전 모델" # corpus 에 실제로 있는 split 만 채택 (무의미 split 은 자동 필터) korean_runs = list(re.finditer(r'[가-힣]+', term)) for run in korean_runs: start, end = run.start(), run.end() if end - start < 4: # 최소 4자여야 split 의미 있음 continue for split_pos in range(start + 2, end - 1): v = term[:split_pos] + ' ' + term[split_pos:] if v != term and v in corpus_text: # 한 쪽이 1글자로 끝나는 경우 제외 (예: "공사비절감" split "공사 비절감") left = term[start:split_pos] right = term[split_pos:end] if len(left) >= 2 and len(right) >= 2: candidates.add(v) # 2) SW ↔ S/W, sw ↔ s/w if 'SW' in term: v = term.replace('SW', 'S/W') if v != term and v in corpus_text: candidates.add(v) v2 = term.replace('SW', ' S/W') if v2 in corpus_text: candidates.add(v2.strip()) if 'S/W' in term: v = term.replace('S/W', 'SW') if v != term and v in corpus_text: candidates.add(v) # 3) Engn. ↔ Engn if 'Engn.' in term: v = term.replace('Engn.', 'Engn') if v != term and v in corpus_text: candidates.add(v) # 4) 공격 정규화 매칭 (corpus 내 동일 정규화 단어 탐색) # corpus 를 단어 단위로 스캔하지 말고, 알려진 후보 pattern 만 term_norm = _normalize(term) # 주변 공백 삽입된 형태들 시도 (8자 이내만) if len(term) <= 20: # term 을 공백 1~2개 삽입해 얻을 수 있는 형태 중 정규화 일치 for i in range(1, len(term)): v = term[:i] + ' ' + term[i:] if v != term and v in corpus_text: if _normalize(v) == term_norm: candidates.add(v) # 슬래시 삽입 v = term[:i] + '/' + term[i:] if v != term and v in corpus_text: if _normalize(v) == term_norm: candidates.add(v) return candidates def loose_compound_variants(term, corpus_text, max_gap=12): """Group B 용 루즈 매칭. 2-part 또는 3-part compound 를 corpus 에서 찾음. 예: '공사비절감' → '공사비 30% 절감' '설계Data' → '설계 업무 시 사업 Data' '추론기반AI' → '추론 기반 AI' (3-part) """ candidates = set() if len(term) < 4: return candidates # 2-part split split_points = set() for m in re.finditer(r'(\d)([A-Za-z가-힣])', term): split_points.add(m.start(2)) for m in re.finditer(r'([A-Za-z])([가-힣])', term): split_points.add(m.start(2)) for m in re.finditer(r'([가-힣])([A-Za-z])', term): split_points.add(m.start(2)) for m in re.finditer(r'([a-z])([A-Z])', term): split_points.add(m.start(2)) if re.match(r'^[가-힣]+$', term): for i in range(2, len(term) - 1): split_points.add(i) for sp in split_points: part_a = term[:sp] part_b = term[sp:] if len(part_a) < 2 or len(part_b) < 2: continue pattern = re.compile( re.escape(part_a) + r'.{0,' + str(max_gap) + r'}' + re.escape(part_b) ) for m in pattern.finditer(corpus_text): match = m.group(0) if match == term: continue if len(match) > len(term) + max_gap + 5: continue if '\n' in match: continue candidates.add(match) if len(candidates) >= 5: break if candidates: break # 2-part 성공했으면 리턴 if candidates: return candidates # 3-part split: 한-영-한 or 한-한-한 혹은 한-한-영 # "추론기반AI": 한-한-영 3 부분. "추론 기반 AI" 찾기 # "시공전모델": 한-한-한. "시공 전 모델" 찾기 # 간이 접근: 모든 경계에서 2회 split 시도 boundaries = sorted(split_points) if len(boundaries) >= 2: for i in range(len(boundaries)): for j in range(i + 1, len(boundaries)): s1, s2 = boundaries[i], boundaries[j] a, b, c = term[:s1], term[s1:s2], term[s2:] if len(a) < 1 or len(b) < 1 or len(c) < 1: continue # "A B C" 또는 "A B C" 로 corpus 에 있는지 patterns = [ re.escape(a) + r'\s?' + re.escape(b) + r'\s?' + re.escape(c), re.escape(a) + r'\s+' + re.escape(b) + r'\s+' + re.escape(c), ] for pat_str in patterns: for m in re.compile(pat_str).finditer(corpus_text): match = m.group(0) if match == term or '\n' in match: continue if len(match) > len(term) + 5: continue candidates.add(match) if len(candidates) >= 3: return candidates return candidates # ─── 도메인 텀 evidence 수집 ────────────────────────── def extract_list_items(text): """texts.md 에서 실제 텍스트 노드 라인만 추출. 포함: - '- xxx' (list item) - 'plain text' (heading/quote 가 아닌 일반 줄 — Frame 27 처럼) 제외: - #/##/### 헤딩 (섹션 제목) - > blockquote/메타 주석 - 빈 줄 - 단일 기호 """ items = [] for line in text.split('\n'): stripped = line.strip() if not stripped: continue # 제외: heading if stripped.startswith('#'): continue # 제외: blockquote/meta if stripped.startswith('>'): continue # list item: '- ' prefix 제거 if re.match(r'^-\s+.+', stripped): content = re.sub(r'^-\s+', '', stripped).strip() else: # plain line — 텍스트 노드로 취급 content = stripped # 단일 기호/너무 짧은 것 제외 if content and content not in {'-', '–', '—', '(', ')', '/', '*', '**', '.'}: items.append(content) return items def load_corpus(): """corpus 별 원문 텍스트 딕트. texts.md 는 list item 만 결합해서 사용 (메타 주석/섹션 제목 배제). MDX 는 원문 전체 그대로 사용. """ corpus = {} # BEPS — texts.md 의 list item 만 beps_path = BLOCKS_DIR / BEPS_ID / "texts.md" if beps_path.exists(): items = extract_list_items(beps_path.read_text(encoding='utf-8')) corpus['BEPS'] = '\n'.join(items) # 32 frames own — texts.md 의 list item 만 for fid in FRAME_IDS: p = BLOCKS_DIR / fid / "texts.md" if p.exists(): items = extract_list_items(p.read_text(encoding='utf-8')) corpus[f'Frame/{fid}'] = '\n'.join(items) # MDX — 원문 전체 (MDX 는 실제 사용자 콘텐츠이므로 모두 유효) for n in ['01', '02', '03']: p = MDX_DIR / f'{n}.mdx' if p.exists(): corpus[f'MDX/{n}'] = p.read_text(encoding='utf-8') return corpus def find_evidence(term, corpus): """term 이 어떤 corpus 에 exact substring 으로 등장하는지. corpus 는 이미 list item 만 결합된 상태 (load_corpus 에서 처리). """ hits = {} for source, text in corpus.items(): if term in text: hits[source] = text.count(term) return hits # ─── anchor term 수집 ─────────────────────────────── def load_anchor_terms(): """templates_v1 에서 (term, owner_frame_id, set_id) 리스트.""" path = HERE / "structure_ontology.yaml" with open(path, encoding='utf-8') as f: data = yaml.safe_load(f) templates = data.get('templates_v1', {}) records = [] for fid, tpl in templates.items(): for s in tpl.get('anchor_sets', []): set_id = s.get('id', '?') for t in s.get('terms', []): records.append((t, fid, set_id)) return records, templates # ─── domain_terms.yaml 힌트 로드 ─────────────────────── def load_synonyms(): """기존 synonyms.yaml 로드 (이미 확정된 canonical 형태들).""" path = HERE / 'synonyms.yaml' if not path.exists(): return {} with open(path, encoding='utf-8') as f: data = yaml.safe_load(f) return data.get('synonyms', {}) def load_domain_hints(): path = HERE / 'domain_terms.yaml' if not path.exists(): return {} with open(path, encoding='utf-8') as f: data = yaml.safe_load(f) # build indexes hints = {'safe_promote': {}, 'abbrev': {}, 'ko_en': {}, 'review': set()} # safe_normalization.promote for entry in data.get('safe_normalization', {}).get('promote', []): hints['safe_promote'][entry['canonical']] = entry.get('variants', []) for entry in data.get('safe_normalization', {}).get('hold', []): hints['safe_promote'][entry['canonical']] = entry.get('variants', []) # abbrev (엄격 필터: 길이 <= 40 + count >= 2 + 문장 조각 배제) for entry in data.get('abbrev_fullname_candidates', []): abbr = entry.get('abbr') full = entry.get('full') count = entry.get('count', 0) if not abbr or not full: continue # 오탐 배제 규칙 if len(full) > 40: continue # 문장 조각 if count < 2: continue # 1회 등장은 우연 가능 # 문장 조각 휴리스틱: 조사/동사 패턴 if re.search(r'(을|를|의|에|에서|으로|인|중심|정보)$', full): continue if '\n' in full or '.' in full: continue hints['abbrev'].setdefault(abbr, []).append((full, count)) # needs_human_review for line in data.get('needs_human_review', []): hints['review'].add(line) return hints # ─── 분류 ──────────────────────────────────────── def classify(term, evidence, variants, hints, synonyms, corpus_text): """Returns (bucket, reason, promote_data).""" # 1) structure for pat in STRUCTURE_PATTERNS: if re.match(pat, term): return ('REMOVE_structure', f'pattern: {pat}', None) if term in STRUCTURE_EXACT: return ('REMOVE_structure', 'explicit structure term', None) # 2) weak row if term in WEAK_ROW_EXACT: return ('REMOVE_weak', 'row label / weak polarity', None) # 2-B) Group A: summary label (AI curator 가 붙인 요약 라벨) if term in SUMMARY_LABEL_EXACT: return ('REMOVE_summary_label', 'AI 요약 라벨 (Group A, 제거 확정)', None) # 3) 현 synonyms.yaml 에 이미 canonical 로 등록됨 — corpus 에 variants 존재 확인 if term in synonyms: registered_variants = synonyms[term] if isinstance(synonyms[term], list) else [] found_variants = [v for v in registered_variants if v in corpus_text] if found_variants: return ('SAFE_variants', f'synonyms.yaml 등록됨, variants in corpus: {found_variants}', {'canonical': term, 'variants': found_variants, 'type': 'synonym_registered'}) else: # synonyms 에는 있으나 corpus 에서 evidence 못 찾음 (약한 evidence) return ('SAFE_evidence', f'synonyms.yaml 등록, corpus evidence 없음 but retain', {'canonical': term, 'variants': registered_variants, 'type': 'synonym_registered_no_corpus'}) # 4) 표기 변형 발견 if variants: return ('SAFE_variants', f'표기 변형: {sorted(variants)[:4]}', {'canonical': term, 'variants': sorted(variants), 'type': 'formatting'}) # 5) 약어 if term in hints.get('abbrev', {}): abbrev_variants = [pair[0] for pair in hints['abbrev'][term]] return ('MEDIUM_abbrev', f'약어 - 풀네임 후보: {abbrev_variants[:3]}', {'canonical': term, 'variants': abbrev_variants, 'type': 'abbreviation'}) # 6) exact evidence if evidence: ev_list = sorted(evidence.keys()) return ('SAFE_evidence', f'evidence: {ev_list[:3]}' + (f' (+{len(ev_list)-3})' if len(ev_list) > 3 else ''), {'canonical': term, 'variants': [], 'type': 'canonical_only'}) # 7) Group C: 유지 후보 (A' 지침: variants 있으면 keep, 없으면 excluded) if term in SUMMARY_KEEP_CANDIDATES: loose_vars = loose_compound_variants(term, corpus_text) if loose_vars: return ('REVIEW_keep_candidate', f'Group C — loose match: {sorted(loose_vars)[:3]}', {'canonical': term, 'variants': sorted(loose_vars), 'type': 'curator_summary_keep', 'normalize': True}) else: # A' 지침: Group C empty → excluded return ('REMOVE_group_c_empty', 'Group C — variants 없음 → A 지침에 따라 excluded', None) # 8) Group C: 개별 판단 — 제거 후보 if term in SUMMARY_REMOVE_CANDIDATES: return ('REVIEW_remove_candidate', 'Group C — matching anchor 로 약함, 제거 권장', None) # 9) Group B: 명시적 keep (A' 지침: variants 있으면 정규화용, 없으면 anchor-only) if term in GROUP_B_KEEP: loose_vars = loose_compound_variants(term, corpus_text) if loose_vars: return ('SAFE_variants', f'Group B loose match: {sorted(loose_vars)[:3]}', {'canonical': term, 'variants': sorted(loose_vars), 'type': 'compound_curator', 'normalize': True}) # A' 지침: Group B empty → keep 하되 synonymic 정규화에는 사용 안 함 (anchor-only) return ('SAFE_groupB_keep', 'Group B — anchor-only (synonymic 정규화 비활성)', {'canonical': term, 'variants': [], 'type': 'compound_curator_anchor_only', 'normalize': False}) # 10) 없음 return ('REMOVE_no_evidence', '어떤 corpus 에도 exact substring 없음 + 변형 탐지 실패', None) # ─── 메인 ─────────────────────────────────────── def main(): print("[1] anchor terms 추출...") records, templates = load_anchor_terms() unique_terms = sorted(set(t for t, _, _ in records)) print(f" 총 {len(records)} entries, 고유 {len(unique_terms)} terms") print("[2] corpus 로드...") corpus = load_corpus() all_corpus_text = '\n'.join(corpus.values()) print(f" sources: {len(corpus)} (BEPS + 32 frames + 3 MDX)") print("[3] domain_terms + 현재 synonyms 힌트 로드...") hints = load_domain_hints() synonyms = load_synonyms() print(f" safe_promote: {len(hints['safe_promote'])}, abbrev: {len(hints['abbrev'])}, review: {len(hints['review'])}") print(f" synonyms.yaml canonical 수: {len(synonyms)}") print("[4] evidence + 변형 대조 + 분류 중...") # ownership map: term → set of owner frame_ids owner_map = {} for term, fid, _ in records: owner_map.setdefault(term, set()).add(fid) results = [] for term in unique_terms: evidence = find_evidence(term, corpus) variants = variant_candidates(term, all_corpus_text) bucket, reason, promote = classify(term, evidence, variants, hints, synonyms, all_corpus_text) results.append({ 'term': term, 'evidence': evidence, 'variants': variants, 'bucket': bucket, 'reason': reason, 'promote': promote, 'owners': sorted(owner_map.get(term, set())), }) print("[5] KEYWORD_BASE_DRAFT.md 작성...") write_draft(results, templates, corpus) print("[6] keyword_base.yaml 초안 작성...") write_keyword_base_yaml(results) # 요약 buckets = {} for r in results: key = r['bucket'] buckets.setdefault(key, []).append(r['term']) print() print("=== 분류 결과 ===") for k in sorted(buckets.keys()): print(f" {k}: {len(buckets[k])}개") def write_draft(results, templates, corpus): lines = [] lines.append("# Keyword Base 초안 (검수용)") lines.append("") lines.append("**Milestone 1 / Step 5 산출물**: `templates_v1` 의 317 고유 anchor term 에 대해 ") lines.append("BEPS (1171281171) + 32 Figma frames + 3 MDX corpus evidence 대조 후 4 bucket 분류.") lines.append("") lines.append(f"- Corpus sources: {len(corpus)} (BEPS 1 + Frames 32 + MDX 3)") lines.append(f"- 총 anchor term: {len(results)} (고유)") lines.append("") # bucket 요약 buckets = {} for r in results: buckets.setdefault(r['bucket'], []).append(r) lines.append("## 분류 요약") lines.append("") lines.append("| Bucket | 수 | 처리 |") lines.append("|--------|------|------|") bucket_desc = { 'SAFE_variants': ('승격 + 표기변형 variants 등록', '승격'), 'SAFE_evidence': ('canonical 단독 승격 (variants 없음)', '승격'), 'SAFE_groupB_keep': ('Group B anchor-only (synonymic 정규화 비활성)', '승격(제한)'), 'MEDIUM_abbrev': ('약어 - 풀네임 검토 후 승격', '검토'), 'REVIEW_keep_candidate': ('Group C — variants 확인 후 유지', '검토/유지'), 'REVIEW_remove_candidate':('Group C — matching 약함, 제거', '검토/제거'), 'RISKY_unknown': ('분류 애매 — 개별 판단', '검토'), 'REMOVE_group_c_empty': ('Group C — variants 미탐지, A 지침에 따라 제거', '제거'), 'REMOVE_summary_label': ('Group A — AI 요약 라벨', '제거'), 'REMOVE_structure': ('구조어 (패턴 또는 exact)', '제거'), 'REMOVE_weak': ('row label / weak polarity', '제거'), 'REMOVE_no_evidence': ('어떤 corpus 에도 evidence 없음', '제거'), } for bk in ['SAFE_evidence', 'SAFE_variants', 'SAFE_groupB_keep', 'MEDIUM_abbrev', 'REVIEW_keep_candidate', 'REVIEW_remove_candidate', 'RISKY_unknown', 'REMOVE_group_c_empty', 'REMOVE_summary_label', 'REMOVE_structure', 'REMOVE_weak', 'REMOVE_no_evidence']: items = buckets.get(bk, []) desc = bucket_desc.get(bk, ('', '')) lines.append(f"| **{bk}** | {len(items)} | {desc[0]} |") lines.append("") # 섹션별 def section(title, bucket_key, with_variants=False, with_evidence=False): items = sorted(buckets.get(bucket_key, []), key=lambda r: r['term']) if not items: return lines.append(f"## {title} ({len(items)}개)") lines.append("") if bucket_key.startswith('SAFE'): lines.append("| term | variants (탐지) | evidence 요약 | owners |") lines.append("|------|-----------------|---------------|--------|") for r in items: vars_str = ', '.join(sorted(r['variants'])) if r['variants'] else '-' ev = list(r['evidence'].keys())[:4] ev_str = ', '.join(ev) if len(r['evidence']) > 4: ev_str += f" (+{len(r['evidence'])-4})" owners = ', '.join(r['owners'][:3]) lines.append(f"| `{r['term']}` | {vars_str} | {ev_str} | {owners} |") elif bucket_key == 'MEDIUM_abbrev': lines.append("| term | 풀네임 후보 | 선택 가이드 |") lines.append("|------|-------------|------------|") for r in items: p = r['promote'] or {} vars_str = ', '.join(p.get('variants', [])) lines.append(f"| `{r['term']}` | {vars_str} | 의미 보존 여부 검수 |") elif bucket_key.startswith('REMOVE'): lines.append("| term | 사유 | 사용하는 anchor_set (owner) |") lines.append("|------|------|------------------------------|") for r in items: owners = ', '.join(r['owners'][:3]) if len(r['owners']) > 3: owners += f" (+{len(r['owners'])-3})" lines.append(f"| `{r['term']}` | {r['reason']} | {owners} |") else: lines.append("| term | 사유 | evidence |") lines.append("|------|------|----------|") for r in items: ev = ', '.join(list(r['evidence'].keys())[:3]) lines.append(f"| `{r['term']}` | {r['reason']} | {ev} |") lines.append("") section("✅ SAFE — evidence 있고 변형 없음 (canonical 단독 승격)", 'SAFE_evidence') section("✅ SAFE — 표기 변형 탐지됨 (canonical + variants 승격)", 'SAFE_variants') section("✅ SAFE — Group B compound 유지 (variants 미탐지)", 'SAFE_groupB_keep') section("🔎 MEDIUM — 약어/풀네임 (검토 후 승격)", 'MEDIUM_abbrev') section("👁️ REVIEW — 유지 후보 (Group C)", 'REVIEW_keep_candidate') section("👁️ REVIEW — 제거 후보 (Group C)", 'REVIEW_remove_candidate') section("⚠️ RISKY — 분류 애매 (개별 판단)", 'RISKY_unknown') section("🗑️ REMOVE — AI 요약 라벨 (Group A)", 'REMOVE_summary_label') section("🗑️ REMOVE — 구조어 (anchor 에서 제거)", 'REMOVE_structure') section("🗑️ REMOVE — weak/row label", 'REMOVE_weak') section("🗑️ REMOVE — evidence 없음 (Group B 미탐지)", 'REMOVE_no_evidence') # 다음 단계 lines.append("## 검수 가이드") lines.append("") lines.append("1. **SAFE 섹션**: 대부분 자동 승격. 이상한 것만 표시.") lines.append("2. **MEDIUM_abbrev**: 약어의 풀네임 중 채택할 variant 선택 (중복/오탐 제거).") lines.append("3. **RISKY_unknown**: 개별 term 별 처리 결정.") lines.append("4. **REMOVE_structure**: anchor_sets 에서 제거 + 해당 frame 의 visual_pattern 에 정보 남음 확인.") lines.append("5. **REMOVE_weak**: 단순 제거.") lines.append("6. **REMOVE_no_evidence**: **가장 중요한 검수 대상** — AI 가 근거 없이 넣은 것일 수 있음.") lines.append("") lines.append("검수 완료 후 `keyword_base.yaml` 작성 → 다음 마일스톤 (Figma anchor 정리) 진입.") out = HERE / "KEYWORD_BASE_DRAFT.md" out.write_text("\n".join(lines), encoding='utf-8') print(f" 완료: {out}") def write_keyword_base_yaml(results): """keyword_base.yaml 초안 생성 — SAFE + MEDIUM + REVIEW_keep 포함.""" PROMOTED = {'SAFE_evidence', 'SAFE_variants', 'SAFE_groupB_keep', 'MEDIUM_abbrev', 'REVIEW_keep_candidate'} EXCLUDED = {'REMOVE_summary_label', 'REMOVE_structure', 'REMOVE_weak', 'REMOVE_no_evidence', 'REVIEW_remove_candidate', 'REMOVE_group_c_empty'} # A' 지침 반영 by_type = { 'canonical_only': [], # SAFE_evidence 'formatting': [], # SAFE_variants 'compound_curator': [], # Group B keep 'synonym_registered': [], # synonyms.yaml 이미 등록 'abbreviation': [], # MEDIUM_abbrev 'curator_summary_keep': [],# Group C keep } excluded_terms = [] for r in results: bk = r['bucket'] if bk in EXCLUDED: excluded_terms.append((r['term'], bk, r['reason'])) continue if bk not in PROMOTED: continue # RISKY skipped from yaml p = r['promote'] if p is None: continue entry = { 'canonical': p['canonical'], 'variants': p.get('variants', []), 'type': p.get('type', 'canonical_only'), 'normalize': p.get('normalize', True), # 기본 True (synonymic 정규화 활성) 'evidence_sources': sorted(r['evidence'].keys())[:3] if r['evidence'] else [], 'owners': r['owners'], } t = p.get('type', 'canonical_only') by_type.setdefault(t, []).append(entry) lines = [] lines.append("# Keyword Base (Milestone 1.6 — A' 지침 적용)") lines.append("#") lines.append("# 생성: build_keyword_base.py") lines.append("# 기준: BEPS(1171281171) + 32 Figma frames + 3 MDX evidence 대조") lines.append("# + synonyms.yaml + domain_terms.yaml 힌트 + Group A/B/C 수동 분류") lines.append("#") lines.append("# 사용:") lines.append("# - Phase 22~25 keyword 축의 canonical vocab") lines.append("# - MDX 전처리 시 variants → canonical 치환 (normalize:true 인 경우만)") lines.append("# - anchor_sets 의 허용 term set") lines.append("#") lines.append("# normalize 필드:") lines.append("# true (기본) — MDX 치환에 사용 + anchor 매칭 사용") lines.append("# false — anchor 매칭에만 사용 (치환 비활성, 오탐 방지)") lines.append("#") lines.append("# ⚠️ Milestone 2 대상 TODO:") lines.append("# - Frame 20 (1171281198) dx_sw_necessity anchor_set 에 '필수성' 이 있음.") lines.append("# '필수성' 은 corpus evidence 없음 → keyword_base 에서 excluded.") lines.append("# Frame 20 anchor_set 업데이트 시 '필수' 또는 '필요' 로 대체 검토.") lines.append("# (Frame 20 실제 텍스트: 'S/W가 필수다', '고도화 필요')") lines.append("") total_promoted = sum(len(v) for v in by_type.values()) lines.append(f"meta:") lines.append(f" schema_version: keyword-base-v1") lines.append(f" total_canonical: {total_promoted}") lines.append(f" excluded: {len(excluded_terms)}") lines.append(f" types:") for t in ['canonical_only', 'formatting', 'compound_curator', 'synonym_registered', 'abbreviation', 'curator_summary_keep']: if by_type.get(t): lines.append(f" {t}: {len(by_type[t])}") lines.append("") lines.append("keywords:") # 정렬: canonical 알파벳 순 all_entries = [] for entries in by_type.values(): all_entries.extend(entries) all_entries.sort(key=lambda e: e['canonical']) for e in all_entries: lines.append(f" {e['canonical']}:") lines.append(f" type: {e['type']}") if e['variants']: lines.append(f" variants:") for v in e['variants']: lines.append(f" - \"{v}\"") else: lines.append(f" variants: []") # normalize flag (기본 True, anchor-only 인 경우 False) if 'normalize' in e and e['normalize'] is False: lines.append(f" normalize: false # anchor-only, MDX 치환 비활성") if e['evidence_sources']: lines.append(f" evidence: [{', '.join(e['evidence_sources'])}]") if e['owners']: lines.append(f" owners: [{', '.join(e['owners'])}]") lines.append("") # Excluded 기록 (audit 용) lines.append("# ═══════════════════════════════════════════════════") lines.append("# Excluded (anchor 에서 제외 — audit 용)") lines.append("# ═══════════════════════════════════════════════════") lines.append("excluded:") for term, bucket, reason in sorted(excluded_terms, key=lambda x: (x[1], x[0])): # single-quoted: only ' needs escaping (as '') safe_term = term.replace("'", "''") lines.append(f" - term: '{safe_term}'") lines.append(f" bucket: {bucket}") reason_clean = reason.replace("'", "''")[:80] lines.append(f" reason: '{reason_clean}'") lines.append("") out = HERE / "keyword_base.yaml" out.write_text("\n".join(lines), encoding='utf-8') print(f" 완료: {out} ({total_promoted} canonical, {len(excluded_terms)} excluded)") if __name__ == "__main__": main()