- 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>
792 lines
34 KiB
Python
792 lines
34 KiB
Python
"""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()
|