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>
This commit is contained in:
2026-07-02 17:03:42 +09:00
co-authored by Claude Opus 4.8
parent 97b7833a1b
commit b836e79ee1
527 changed files with 673036 additions and 717 deletions
+791
View File
@@ -0,0 +1,791 @@
"""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()