Files
KyeongminandClaude Opus 4.8 b836e79ee1 wip: phase_z2 evidence 파이프라인 + matching 실험(phase2~26) + 프론트 trace 패널 진행분 스냅샷
- src: phase_z2 composition/mapper/pipeline/placement_planner/retry, ai_fallback(prompts/schema/validate), mdx_text_atoms 신규
- Front: PipelineTracePanel 신규, FramePanel/SlideCanvas/Home/designAgentApi 등 갱신 + 테스트 4종 추가
- templates/phase_z2: catalog(component_expansion_registry, node_slot_mapping 신규), frames, families, slide_base 갱신
- tests/matching: phase2~26 매칭 실험 스크립트·리포트·온톨로지 전체 (미커밋 진행분)
- tests: b4_v4 evidence, task5~28.5 시리즈, regression(imp95 baseline) 등 신규 테스트 대량 추가
- docs/reference: MDX 구조 인벤토리, MDX→Frame 구조 계약 문서
- scripts: mdx 계약/parity/coverage/viewport 체크, gitea comment, run sync 유틸
- .gitignore: tmp*.json, chromedriver, .orchestrator, *.pkl, Front_test* 등 임시/스냅샷 제외

미완성 작업의 보존용 스냅샷 커밋 (2026-07-02)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 17:03:42 +09:00

792 lines
34 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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()