Files
C.E.L_Slide_test2/tests/matching/pipeline_13_r2_meeting_docs.py
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

901 lines
49 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.
"""Pipeline Step 13-r2 — 회의 자료 최종 세트 (7개 파일, A4 규격).
사용자 확정 구조 (5-step narrative):
1. 개요 + 기존 라이브러리/모델 시도 (tf-idf, sobert, BM25, cross-encoder)
2. AI 디자인 분석 시도
3. 키워드 전처리 + 가중치 매칭 → 첨부 1, 2
4. 키워드 + 의미/구조/적용 가능성 → 첨부 3, 4, 5
5. Figma 프레임별 추출 및 정리 방안 → 첨부 6
산출:
MEETING_BRIEF.html (1장 요약 — 업데이트)
ATTACH_01_KEYWORD_ORGANIZATION.html
ATTACH_02_KEYWORD_MATCHING.html
ATTACH_03_CASCADE_RESULT.html
ATTACH_04_STRUCTURE_VOCAB.html (신규)
ATTACH_05_SAMPLES.html (신규)
ATTACH_06_DB_ORGANIZATION.html
"""
import base64
import re
import sys
from collections import Counter
from pathlib import Path
import yaml
HERE = Path(__file__).parent
PNG_DIR = (HERE / '..' / '..' / 'data' / 'figma_previews').resolve()
sys.path.insert(0, str(HERE))
MEETING_BRIEF = HERE / 'MEETING_BRIEF.html'
ATTACH_01 = HERE / 'ATTACH_01_KEYWORD_ORGANIZATION.html'
ATTACH_02 = HERE / 'ATTACH_02_KEYWORD_MATCHING.html'
ATTACH_03 = HERE / 'ATTACH_03_CASCADE_RESULT.html'
ATTACH_04 = HERE / 'ATTACH_04_STRUCTURE_VOCAB.html'
ATTACH_05 = HERE / 'ATTACH_05_SAMPLES.html'
ATTACH_06 = HERE / 'ATTACH_06_DB_ORGANIZATION.html'
TARGET_SIDS = ['01-2', '02-2.2', '03-1', '03-2']
_IMG_CACHE = {}
def img_uri(fn):
if fn in _IMG_CACHE:
return _IMG_CACHE[fn]
p = PNG_DIR / f'{fn:02d}.png'
if not p.exists():
return ''
uri = 'data:image/png;base64,' + base64.b64encode(p.read_bytes()).decode('ascii')
_IMG_CACHE[fn] = uri
return uri
def extract_mdx_raw(sid):
from pipeline_01_extract_nodes import MDX_SECTIONS, MDX_DIR
cfg = MDX_SECTIONS[sid]
p = MDX_DIR / cfg['file']
lines = p.read_text(encoding='utf-8').split('\n')
start = None
for i, ln in enumerate(lines):
if ln.strip() == cfg['start'].strip():
start = i
break
end = len(lines)
if cfg.get('end_prefix'):
for i in range(start + 1, len(lines)):
if lines[i].strip().startswith(cfg['end_prefix']):
end = i
break
section = lines[start:end]
return section[0].lstrip('#').strip(), '\n'.join(section[1:])
def mdx_excerpt(raw, max_chars=220):
"""MDX 본문에서 **실제 문장** 2-3줄 추출.
JSX style block / 이미지 마크다운 / HTML 태그 / 표 구분자는 모두 제외.
List item (`*`, `-`) 은 유지 — 실제 콘텐츠가 여기 있는 경우 많음.
"""
lines = raw.split('\n')
buf = []
in_jsx_block = False
for ln in lines:
s = ln.strip()
# JSX 블록 진입/종료 (<div>, <span>, <details>, <summary> 등)
if in_jsx_block:
if s.endswith('}}>') or re.search(r'</(div|span|details|summary)\s*>', s):
in_jsx_block = False
continue
if re.match(r'^<(div|span|details|summary)\b', s):
# 같은 줄에서 닫히면 skip only, 그렇지 않으면 block 진입
if not re.search(r'(/>|</(div|span|details|summary)>)\s*$', s):
in_jsx_block = True
continue
# 빈 줄
if not s:
continue
# 구조 마커 스킵 (MDX / JSX / admonition / 표 / 코드 / 이미지)
if s.startswith(('#', '|', '```', '---', '>', '![', '<', ':')):
continue
# 번호 리스트
if re.match(r'^\d+\.\s', s):
continue
# JSX 속성 잔재 (fontSize: '0.8rem' 등)
if re.match(r"^\w+\s*:\s*['\"\d#]", s):
continue
# 단독 `})>` 같은 닫기
if re.match(r"^[\}\)\]>/'\"\s,]+$", s):
continue
# 리스트 마커 제거 + 내부 markdown bold/italic 제거
if s.startswith(('* ', '- ')):
s = s[2:].strip()
s = re.sub(r'\*\*([^*]+)\*\*', r'\1', s) # bold
s = re.sub(r'(?<!\*)\*([^*]+)\*(?!\*)', r'\1', s) # italic
s = re.sub(r'`([^`]+)`', r'\1', s) # inline code
# 너무 짧거나 한글/영문자 없는 줄 스킵
if len(s) < 6 or not re.search(r'[가-힣A-Za-z]', s):
continue
buf.append(s)
if len(buf) >= 3 or sum(len(x) for x in buf) > max_chars:
break
text = ' / '.join(buf[:3])
if len(text) > max_chars:
text = text[:max_chars - 1] + '…'
return text or '(본문 요약 추출 실패)'
def build_frame_descriptions(auto):
out = {}
for fid, info in auto['frame_stats'].items():
fnum = info['frame_number']
for set_id, sinfo in auto['source_text_sets'].items():
if sinfo['frame_id'] == fid and sinfo['source_text_index'] == 1:
out[fnum] = sinfo['source_text_raw']
break
if fnum not in out:
out[fnum] = f'Frame {fnum}'
return out
MATCHED_SIDS = TARGET_SIDS
UNMATCHED_SIDS = ['01-1', '02-2.1'] # V1 에서 합리적으로 매칭되지 않은 Holdout 2건
A4_CSS = """
@page { size: A4 portrait; margin: 14mm 14mm 16mm 14mm; }
* { box-sizing: border-box; }
body {
font-family: -apple-system, "Segoe UI", Pretendard, "Malgun Gothic", "Apple SD Gothic Neo", sans-serif;
background: white; color: #1e293b; margin: 0;
font-size: 10pt; line-height: 1.45;
}
@media print {
body { margin: 0; }
.avoid-break { page-break-inside: avoid; }
.page-break { page-break-before: always; }
}
@media screen {
body { max-width: 210mm; margin: 10mm auto; box-shadow: 0 4px 24px rgba(0,0,0,0.12); padding: 14mm; background: #fff; }
}
h1 { font-size: 15pt; border-bottom: 2pt solid #2563eb; padding-bottom: 4pt; margin: 0 0 6pt 0; color: #0f172a; }
h2 { font-size: 11pt; color: #1e293b; margin: 10pt 0 5pt; padding: 3pt 6pt; background: #e0e7ff; border-left: 3pt solid #0a6; border-radius: 2pt; }
h3 { font-size: 10pt; margin: 6pt 0 3pt; color: #1a365d; font-weight: 700; }
p { margin: 4pt 0; }
.meta-bar { color: #64748b; font-size: 8.5pt; margin: -3pt 0 8pt; font-style: italic; }
.note { background: #fef3c7; border-left: 3pt solid #f59e0b; padding: 4pt 8pt; margin: 6pt 0; font-size: 9pt; color: #78350f; border-radius: 2pt; }
.fail-note { background: #fee2e2; border-left: 3pt solid #dc2626; padding: 4pt 8pt; margin: 4pt 0; font-size: 9pt; color: #991b1b; border-radius: 2pt; }
.ok-note { background: #d1fae5; border-left: 3pt solid #059669; padding: 4pt 8pt; margin: 4pt 0; font-size: 9pt; color: #065f46; border-radius: 2pt; }
.mdx-card { background: #f8fafc; border: 0.5pt solid #cbd5e1; border-radius: 4pt; padding: 6pt 8pt; margin: 4pt 0; }
.mdx-title { font-weight: 700; color: #0f172a; font-size: 10.5pt; margin-bottom: 2pt; }
.mdx-excerpt { color: #475569; font-size: 9pt; margin: 2pt 0 4pt; line-height: 1.4; }
.kw-chip { display: inline-block; padding: 1pt 6pt; background: #dbeafe; color: #1e40af; border-radius: 8pt; font-size: 8pt; margin: 1pt 2pt 1pt 0; font-family: ui-monospace, monospace; }
.kw-set-chip { background: #fce7f3; color: #9f1239; }
.kw-rel-chip { background: #e0e7ff; color: #3730a3; }
table { border-collapse: collapse; width: 100%; font-size: 9pt; margin: 4pt 0; }
th, td { border: 0.5pt solid #cbd5e1; padding: 3pt 5pt; text-align: left; vertical-align: top; }
th { background: #1e293b; color: white; font-weight: 700; font-size: 8.5pt; }
code { background: #f1f5f9; padding: 0 3pt; border-radius: 2pt; font-family: ui-monospace, Menlo, monospace; font-size: 8.5pt; color: #0f172a; }
.frame-thumb { max-width: 100%; height: auto; border: 0.5pt solid #94a3b8; border-radius: 3pt; display: block; }
.frame-cell { text-align: center; vertical-align: top; padding: 3pt; }
.frame-label { font-size: 8.5pt; color: #0f172a; font-weight: 600; margin-top: 2pt; }
.frame-score { font-family: ui-monospace, monospace; font-size: 8pt; color: #059669; }
.frame-desc { font-size: 7.5pt; color: #64748b; line-height: 1.25; margin-top: 1pt; }
.label-badge { display: inline-block; padding: 1pt 5pt; border-radius: 2pt; font-size: 7.5pt; font-weight: 700; }
.label-use_as_is { background: #d1fae5; color: #065f46; }
.label-light_edit { background: #dbeafe; color: #1e40af; }
.label-restructure { background: #fef3c7; color: #92400e; }
.label-reject { background: #fee2e2; color: #991b1b; }
.answer-box { background: #fff3cd; border: 1.5pt solid #dc2626; border-radius: 3pt; padding: 2pt; }
.flex-grid { display: table; width: 100%; border-spacing: 0; }
.flex-row { display: table-row; }
.flex-cell { display: table-cell; vertical-align: top; padding: 2pt; }
ol.steps { margin: 4pt 0; padding-left: 16pt; }
ol.steps li { margin: 3pt 0; line-height: 1.5; }
.step-title { font-weight: 700; color: #1e293b; }
.step-fail { color: #991b1b; font-size: 8.5pt; font-style: italic; }
.step-ok { color: #065f46; font-size: 8.5pt; }
/* V1 매칭 상세 (ATTACH_01) 전용 */
.page-header-small { font-size: 8pt; color: #64748b; text-align: right; margin: 0 0 2pt 0; }
.frame-match { border: 0.5pt solid #cbd5e1; border-radius: 4pt; padding: 6pt 8pt; margin: 4pt 0; background: #fafafa; }
.frame-match.answer { border: 1pt solid #dc2626; background: #fffbeb; }
.frame-match h3 { margin: 0 0 3pt 0; font-size: 10pt; }
.fm-body { display: table; width: 100%; }
.fm-thumb { display: table-cell; width: 32mm; vertical-align: top; padding-right: 6pt; }
.fm-thumb img { max-width: 30mm; height: auto; border: 0.5pt solid #94a3b8; border-radius: 2pt; display: block; }
.fm-thumb .frame-desc { font-size: 7pt; color: #64748b; margin-top: 2pt; line-height: 1.25; }
.fm-detail { display: table-cell; vertical-align: top; }
.kw-line { margin: 3pt 0; font-size: 8.5pt; line-height: 1.4; }
.kw-line strong { color: #0f172a; font-weight: 700; }
.hit-chip { color: #065f46; background: #d1fae5; padding: 1pt 5pt; border-radius: 2pt; font-size: 8pt; display: inline-block; margin: 0 2pt; }
.miss-chip { color: #991b1b; background: #fee2e2; padding: 1pt 5pt; border-radius: 2pt; font-size: 8pt; display: inline-block; margin: 0 2pt; }
.hit-inline { color: #065f46; font-weight: 600; }
.miss-inline { color: #991b1b; }
.ratio { color: #64748b; font-family: ui-monospace, monospace; font-size: 7.5pt; }
.frame-match ul { margin: 2pt 0; padding-left: 14pt; font-size: 8.5pt; }
.frame-match li { line-height: 1.4; margin: 1pt 0; }
.section-tag { display: inline-block; padding: 2pt 6pt; border-radius: 2pt; font-size: 8pt; font-weight: 700; margin-left: 4pt; vertical-align: middle; }
.tag-matched { background: #d1fae5; color: #065f46; }
.tag-unmatched { background: #fee2e2; color: #991b1b; }
"""
def html_wrap(title, body):
return f"""<!DOCTYPE html>
<html lang="ko"><head><meta charset="utf-8"><title>{title}</title>
<style>{A4_CSS}</style></head><body>{body}</body></html>"""
# ============================================================
# MEETING BRIEF (업데이트 — 5-step narrative)
# ============================================================
def build_meeting_brief():
body = []
body.append('<h1>MDX ↔ Figma 프레임 자동 매칭 — 현황 및 제안</h1>')
body.append('<div class="meta-bar">첨부 6장과 함께 검토. 이 한 장이 전체 요약.</div>')
body.append('<h2>1. 개요</h2>')
body.append('<ul style="margin:3pt 0;padding-left:14pt;font-size:9.5pt;line-height:1.5">')
body.append('<li>CEL 각 섹션별 내용(MDX)에 <strong>가장 적합한 Figma 프레임을 매칭</strong>하는 프로세스 확인</li>')
body.append('<li>실행 내용: Figma 디자인에서 <strong>텍스트 요소</strong>를 추출하여 MDX와의 유사도를 통한 매칭 실시</li>')
body.append('</ul>')
# 5-step narrative
body.append('<h2>2. 시행 단계</h2>')
body.append('<ol class="steps">')
body.append('''
<li><span class="step-title">기존 라이브러리/모델 적용</span> — tf-idf, sobert, BM25, cross-encoder 등 적용
<div class="step-fail">(*) 일반적인 단어, 동음이의/도메인 용어 부재 등의 요인으로 매칭 점수 낮거나 미매칭.
예: "전환"(일반) vs "디지털 전환"(DX) 혼동, BEPs 고유어(결과혁신/과정혁신 등) 학습 안 됨.</div>
</li>
''')
body.append('''
<li><span class="step-title">AI로 디자인 분석하여 매칭</span>
<div class="step-fail">(*) 없는 단어, 추론 단어 등의 요인으로 매칭 점수 낮게 나오며, 구조가 맞지 않음.</div>
</li>
''')
body.append('''
<li><span class="step-title">키워드 전처리 및 가중치를 통한 매칭</span> <span style="color:#2563eb;font-size:8.5pt">(첨부 1. 키워드 정리)</span>
<div style="font-size:8.5pt;margin:2pt 0;color:#475569">
(*) Figma에서 추출한 텍스트에서 해당 디자인의 <strong>핵심 키워드 / 세트 키워드 / 연관 키워드</strong>를 구분,
MDX에서 해당 키워드를 통해 매칭 점수 정리 <span style="color:#2563eb">(첨부 2. 키워드 매칭)</span>
</div>
<div class="step-ok">→ 32개 디자인 중 적합한 프레임 매칭. 하지만 2~3순위 프레임을 활용하기엔 부적절.</div>
<div class="step-fail">⇒ BEPs에 없는 내용에 대한 디자인 매칭 시 구조에 적합하지 않은 디자인 매칭. <span style="color:#2563eb">(첨부 2. 키워드 매칭 — 한계)</span></div>
</li>
''')
body.append('''
<li><span class="step-title">키워드 매칭 + 의미 / 구조 / 적용 가능성을 통한 매칭</span> <span style="color:#2563eb;font-size:8.5pt">(첨부 3. 매칭 결과)</span>
<div style="font-size:8.5pt;margin:2pt 0;color:#475569">
(*) 매칭 결과 표
</div>
<div class="step-ok">→ 구조 매칭 시, 내용에 기반하여 적합한 디자인과 매칭 <span style="color:#2563eb">(첨부 4. 구조 매칭 키워드)</span></div>
<div class="step-ok">(*) 단순 구조 매칭이 아닌 <strong>fit-v2</strong>가 가장 적합 <span style="color:#2563eb">(첨부 5. 샘플)</span></div>
</li>
''')
body.append('''
<li><span class="step-title">Figma 프레임별 추출 내용 및 정리 방안</span>
<div style="font-size:8.5pt;margin:2pt 0;color:#475569">
→ 32개 프레임 외, BEPs에서의 디자인들을 프레임별로 추출 및 정리 <span style="color:#2563eb">(첨부 6. 정리 방안)</span>
</div>
<div class="step-ok">→ 테스트를 늘려가며 조금 더 조정할 필요</div>
</li>
''')
body.append('</ol>')
# 첨부 목차
body.append('<h2>첨부 6장</h2>')
body.append('''
<table style="margin-top:3pt"><thead><tr>
<th style="width:6%">#</th><th style="width:36%">파일</th><th>내용</th>
</tr></thead><tbody>
<tr><td><strong>1</strong></td><td><code>ATTACH_01_KEYWORD_ORGANIZATION.html</code></td><td>Figma 프레임별 3-layer 키워드 정리 (핵심/세트/연관)</td></tr>
<tr><td><strong>2</strong></td><td><code>ATTACH_02_KEYWORD_MATCHING.html</code></td><td>V1 키워드 매칭 결과 + 한계 사례 (BEPs 밖 콘텐츠)</td></tr>
<tr><td><strong>3</strong></td><td><code>ATTACH_03_CASCADE_RESULT.html</code></td><td>V1 키워드 → + 의미 → + 구조 → + 적용 가능성 단계별 결과</td></tr>
<tr><td><strong>4</strong></td><td><code>ATTACH_04_STRUCTURE_VOCAB.html</code></td><td>구조 매칭 키워드 정의 (content_affinity 12, structure_intent 9, alt_patterns)</td></tr>
<tr><td><strong>5</strong></td><td><code>ATTACH_05_SAMPLES.html</code></td><td>fit-v2 적합 샘플 3건 (V1 강함/약함/reject 각 사례)</td></tr>
<tr><td><strong>6</strong></td><td><code>ATTACH_06_DB_ORGANIZATION.html</code></td><td>Figma DB 형태 (Frame 예시 + 32 프레임 요약)</td></tr>
</tbody></table>
''')
return html_wrap('MDX ↔ Figma 매칭 — 현황 및 제안', ''.join(body))
# ============================================================
# ATTACH 01 — V1 키워드 매칭 상세 breakdown (6 페이지)
# Page 1-4: TARGET 4 (매칭된 샘플)
# Page 5-6: Holdout 01-1, 02-2.1 (매칭 안된 샘플)
# ============================================================
def _render_frame_match_block(fid, fn, score, rank_label, is_answer, detail, frame_desc):
"""프레임 1개 매칭 breakdown (compact).
포맷:
핵심 키워드: (o) kw1, kw2 등 N개 / (x) kw3, kw4 등 M개
키워드 세트: (o) [k,k,k], [k,k] 등 N개 / (x) [k,k], [k,k,k] 등 M개
연관 키워드: (o) kw1, kw2 등 N개 / (x) 미매칭 M개
"""
MAX_INLINE_KW = 5 # "등 N개" 앞에 최대 몇 개 나열
MAX_INLINE_SETS = 3 # 세트도 최대 몇 개 나열
def trim_list(items, max_n):
"""items 를 max_n 개까지만 보여주고 나머지는 '등 N개'."""
n = len(items)
if n == 0:
return '—'
shown = items[:max_n]
if n > max_n:
return ', '.join(shown) + f' 등 {n}개'
return ', '.join(shown) + f' ({n}개)'
def trim_sets(sets_list, max_n):
n = len(sets_list)
if n == 0:
return '—'
shown = sets_list[:max_n]
formatted = ', '.join('[' + ', '.join(s) + ']' for s in shown)
if n > max_n:
return formatted + f' 등 {n}개'
return formatted + f' ({n}개)'
html = []
box_class = 'frame-match answer' if is_answer else 'frame-match'
html.append(f'<div class="{box_class} avoid-break">')
badge = ' 🎯 정답' if is_answer else ''
html.append(f'<h3>Frame {fn}{badge} '
f'<span style="color:#64748b;font-weight:400;font-size:9pt">'
f'({rank_label} · 매칭점수 <code>{score:.3f}</code>)</span></h3>')
html.append('<div class="fm-body">')
html.append('<div class="fm-thumb">')
html.append(f'<img src="{img_uri(fn)}" alt="{fn:02d}"/>')
if frame_desc:
html.append(f'<div class="frame-desc">{frame_desc[:40]}</div>')
html.append('</div>')
html.append('<div class="fm-detail">')
# 핵심 (단독 대표 키워드): hit vs miss 나열, 개수 명시
standalone = detail.get('standalone', {})
s_total = standalone.get('total', [])
s_hit = set(standalone.get('hit', []))
s_hit_list = sorted(s_hit)
s_miss_list = sorted([t for t in s_total if t not in s_hit])
html.append('<div class="kw-line"><strong>핵심 키워드</strong><br/>')
html.append(f' <span class="hit-chip">(o) {trim_list(s_hit_list, MAX_INLINE_KW)}</span>')
html.append(f' <span class="miss-chip">(x) {trim_list(s_miss_list, MAX_INLINE_KW)}</span>')
html.append('</div>')
# 세트 (키워드 묶음): coverage 기준 (o)/(x) 분리, 세트를 [kw,kw,kw] 형태로
groups = detail.get('keyword_group', {}).get('groups', [])
hit_sets = [] # coverage >= 0.5 (의미 있는 매칭)
miss_sets = [] # coverage < 0.5
for g in sorted(groups, key=lambda x: -x.get('coverage', 0)):
keywords = list(g.get('keywords', []))
if g.get('coverage', 0) >= 0.5:
hit_sets.append(keywords)
else:
miss_sets.append(keywords)
html.append('<div class="kw-line"><strong>키워드 세트</strong><br/>')
html.append(f' <span class="hit-chip">(o) {trim_sets(hit_sets, MAX_INLINE_SETS)}</span>')
html.append(f' <span class="miss-chip">(x) {trim_sets(miss_sets, MAX_INLINE_SETS)}</span>')
html.append('</div>')
# 연관 키워드: hit 나열 + miss 개수
related = detail.get('related', {})
r_total = related.get('total_count', 0)
r_hit_list = sorted(related.get('hit', []))
r_hit_ct = related.get('hit_count', len(r_hit_list))
r_miss_ct = r_total - r_hit_ct
html.append('<div class="kw-line"><strong>연관 키워드</strong><br/>')
html.append(f' <span class="hit-chip">(o) {trim_list(r_hit_list, MAX_INLINE_KW)}</span>')
html.append(f' <span class="miss-chip">(x) 미매칭 {r_miss_ct}개</span>')
html.append('</div>')
html.append('</div>') # close fm-detail
html.append('</div>') # close fm-body
html.append('</div>') # close frame-match
return ''.join(html)
def build_attach01(v1, descriptions):
"""6-page V1 매칭 상세 breakdown. 각 page 1 MDX 섹션 + Top-3 프레임."""
body = []
body.append('<h1>첨부 1 — V1 키워드 매칭 상세</h1>')
body.append('<div class="meta-bar">MDX 섹션마다 Top-3 프레임의 <strong>매칭 근거</strong>. '
'각 프레임의 핵심/세트/연관 키워드 중 어떤 것이 MDX 에 등장했는지 (o/x) 확인. '
'TARGET 4 (매칭된 샘플) + Holdout 2 (매칭 안된 샘플) = 총 6 페이지.</div>')
all_sids_ordered = [
(sid, True) for sid in MATCHED_SIDS
] + [
(sid, False) for sid in UNMATCHED_SIDS
]
total_pages = len(all_sids_ordered)
for idx, (sid, is_matched) in enumerate(all_sids_ordered):
if idx > 0:
body.append('<div class="page-break"></div>')
page_num = idx + 1
body.append(f'<div class="page-header-small">샘플 {page_num} / {total_pages}</div>')
title, raw = extract_mdx_raw(sid)
# MDX 요약 (2-3줄, 약 200자)
excerpt = mdx_excerpt(raw, 220)
tag = ('<span class="section-tag tag-matched">매칭됨</span>' if is_matched
else '<span class="section-tag tag-unmatched">매칭 안됨</span>')
body.append(f'<h1>MDX {sid} · {title} {tag}</h1>')
body.append(f'<div class="mdx-card"><div class="mdx-excerpt">{excerpt}</div></div>')
v1_sec = v1['mdx_sections'][sid]
answer = v1_sec.get('answer_frame_number')
top3 = v1_sec['rank_by_matching_score'][:3]
per_frame = v1_sec.get('per_frame_detail', {})
body.append('<h2>프레임별 매칭 breakdown (Top-3)</h2>')
for rank_idx, r in enumerate(top3):
fid = r['frame_id']
fn = r['frame_number']
score = r['matching_score']
detail = per_frame.get(fid, {})
is_ans = (answer is not None) and (fn == answer)
rank_label = f'{rank_idx + 1}순위'
body.append(_render_frame_match_block(
fid, fn, score, rank_label, is_ans, detail,
descriptions.get(fn, ''),
))
return html_wrap('첨부 1 — V1 키워드 매칭 상세', ''.join(body))
# ============================================================
# ATTACH 02 — 키워드 매칭 + 한계
# ============================================================
def build_attach02(v1, normalized, descriptions):
body = []
body.append('<h1>첨부 2 — 키워드 매칭 결과 + 한계</h1>')
body.append('<div class="meta-bar">MDX 섹션별 추출 키워드 × 32 프레임 키워드 매칭. 결과 + 2~3순위의 한계 / BEPs 밖 콘텐츠 한계.</div>')
body.append('<h2>매칭 공식</h2>')
body.append('<table><thead><tr><th style="width:30%">신호</th><th style="width:50%">의미</th><th style="width:20%">가중치</th></tr></thead><tbody>')
body.append('<tr><td>단독 대표 키워드</td><td>특정 프레임을 혼자서도 강하게 가리키는 키워드 매칭</td><td><code>0.30</code></td></tr>')
body.append('<tr><td>세트 키워드 묶음</td><td>프레임 원문 한 줄에서 함께 등장하는 키워드 조합 매칭</td><td><code>0.50</code></td></tr>')
body.append('<tr><td>연관 키워드</td><td>참고 키워드 겹침 정도</td><td><code>0.20</code></td></tr>')
body.append('</tbody></table>')
body.append('<h2>TARGET 4 섹션 결과</h2>')
for sid in TARGET_SIDS:
title, raw = extract_mdx_raw(sid)
v1_sec = v1['mdx_sections'][sid]
answer = v1_sec.get('answer_frame_number')
top3 = v1_sec['rank_by_matching_score'][:3]
top_score = top3[0]['matching_score']
gap = top_score - top3[1]['matching_score']
strength = '강함' if top_score >= 0.70 else ('약함' if top_score >= 0.40 else '매우 약함')
strength_color = '#065f46' if top_score >= 0.70 else ('#92400e' if top_score >= 0.40 else '#991b1b')
body.append('<div class="avoid-break mdx-card">')
body.append(f'<div class="mdx-title">{sid} · {title}</div>')
body.append(f'<div style="font-size:8.5pt;color:#475569">1위 점수 <code>{top_score:.3f}</code> '
f'<span style="color:{strength_color};font-weight:700">[{strength}]</span> · 1-2위 격차 <code>{gap:.3f}</code></div>')
body.append('<div class="flex-grid" style="margin-top:3pt"><div class="flex-row">')
for r in top3:
fn = r['frame_number']
is_ans = fn == answer
inner = (f'<img src="{img_uri(fn)}" class="frame-thumb" style="max-width:38mm"/>'
f'<div class="frame-label">Frame {fn}{"" if not is_ans else " 🎯"}</div>'
f'<div class="frame-score">{r["matching_score"]:.3f}</div>'
f'<div class="frame-desc">{descriptions.get(fn, "")[:24]}</div>')
if is_ans:
inner = f'<div class="answer-box">{inner}</div>'
body.append(f'<div class="frame-cell flex-cell">{inner}</div>')
body.append('</div></div></div>')
body.append('<h2 class="page-break">한계 사례</h2>')
body.append('<h3>한계 1. 2~3순위 프레임이 부적절</h3>')
body.append('<div class="fail-note">1위는 대체로 맞지만, V1 점수만으로는 2~3위가 같은 수준의 대안인지 '
'단순 키워드 중복으로 올라왔는지 구분 어려움. 예: 01-2 의 2위 Frame 27 (score 0.392) 은 '
'키워드는 겹치지만 구조(central-split)는 부적합.</div>')
body.append('<h3>한계 2. BEPs 에 없는 콘텐츠 — 구조 부적합 매칭</h3>')
# Holdout 02-2.1 예시
try:
hd = v1['mdx_sections']['02-2.1']
hd_title, _ = extract_mdx_raw('02-2.1')
hd_top = hd['rank_by_matching_score'][0]
body.append(f'<div class="fail-note">Holdout <strong>02-2.1</strong> · {hd_title}<br>'
f'V1 1위: Frame {hd_top["frame_number"]} (score <code>{hd_top["matching_score"]:.3f}</code>) → '
f'사용자 검수 결과 <strong>비합리</strong>. '
f'BEPs 프레임 라이브러리에 원래 이 유형의 콘텐츠가 없어서, 키워드 우연 일치가 '
f'1위로 올라와 버림. V4 에서도 reject 판정. '
f'→ <strong>기존 프레임에 없는 콘텐츠 유형 = 재구성 필요 신호</strong>.</div>')
except Exception:
pass
body.append('<div class="note">⇒ V1 (키워드) 만으로는 부족. 의미 / 구조 / 적용 가능성 추가 필요 (첨부 3 이후).</div>')
return html_wrap('첨부 2 — 키워드 매칭 + 한계', ''.join(body))
# ============================================================
# ATTACH 03 — V1~V4 CASCADE 결과 (기존 SAMPLE 2 재사용)
# ============================================================
def build_attach03(v1, v2, v3_r5, v4_r2, descriptions):
body = []
body.append('<h1>첨부 3 — 매칭 결과 (V1→V2→V3→V4 캐스케이드)</h1>')
body.append('<div class="meta-bar">같은 MDX 섹션에 대해 키워드(V1) → 의미(V2) → 구조(V3) → '
'적용 가능성(V4) 순서대로 후보를 재정렬/판정. TARGET 4 섹션 모두 V4 1위 = 정답.</div>')
for sid in TARGET_SIDS:
title, raw = extract_mdx_raw(sid)
excerpt = mdx_excerpt(raw, 140)
v1_sec = v1['mdx_sections'][sid]
answer = v1_sec.get('answer_frame_number')
v1_top = v1_sec['rank_by_matching_score'][0]
v2_top = sorted(v2['mdx_sections'][sid]['v2_rerank'], key=lambda x: x['v2_rank'])[0]
v3_top = sorted(v3_r5['mdx_sections'][sid]['v3_r5_rerank'], key=lambda x: x['v3_r5_rank'])[0]
v4_top = sorted(v4_r2['mdx_sections'][sid]['v4_r2_judgments'], key=lambda x: x['v4_r2_rank'])[0]
body.append('<div class="avoid-break">')
body.append(f'<h2>{sid} · {title} (정답 Frame {answer})</h2>')
body.append(f'<div class="mdx-card"><div class="mdx-excerpt">{excerpt}</div></div>')
body.append('<table><thead><tr>')
for v, desc in [('V1', '키워드'), ('V2', '+ 의미'), ('V3', '+ 구조'), ('V4', '+ 적용가능성')]:
body.append(f'<th>{v}<br><span style="font-weight:400;font-size:8pt;color:#cbd5e1">{desc}</span></th>')
body.append('</tr></thead><tbody><tr>')
def cell(frame, score_label, is_ans=False, extra=''):
fn = frame
inner = (f'<img src="{img_uri(fn)}" class="frame-thumb" style="max-width:38mm"/>'
f'<div class="frame-label">Frame {fn}{"" if not is_ans else " 🎯"}</div>'
f'<div class="frame-score">{score_label}</div>'
f'<div class="frame-desc">{descriptions.get(fn, "")[:22]}</div>')
if extra:
inner += f'<div style="margin-top:2pt">{extra}</div>'
if is_ans:
inner = f'<div class="answer-box">{inner}</div>'
return f'<td class="frame-cell">{inner}</td>'
fn1 = v1_top['frame_number']
body.append(cell(fn1, f"score {v1_top['matching_score']:.3f}", fn1 == answer))
fn2 = v2_top['frame_number']
body.append(cell(fn2, f"sem {v2_top['semantic_score']:.3f}", fn2 == answer))
fn3 = v3_top['frame_number']
body.append(cell(fn3, f"struct {v3_top['v3_r5_total']:.3f}", fn3 == answer))
fn4 = v4_top['frame_number']
label = v4_top['label']
extra4 = f'<span class="label-badge label-{label}">{label}</span>'
body.append(cell(fn4, f"conf {v4_top['confidence']:.3f}", fn4 == answer, extra4))
body.append('</tr></tbody></table></div>')
body.append('<div class="note">핵심: V4 가 최종 의사결정. V4 label '
'<span class="label-badge label-use_as_is">use_as_is</span> / '
'<span class="label-badge label-light_edit">light_edit</span> 면 실사용 가능, '
'<span class="label-badge label-reject">reject</span> 면 기존 프레임 부적합 → 재구성 신호.</div>')
return html_wrap('첨부 3 — 매칭 결과', ''.join(body))
# ============================================================
# ATTACH 04 — 구조 매칭 키워드 (controlled vocab)
# ============================================================
def build_attach04():
body = []
body.append('<h1>첨부 4 — 구조 매칭 키워드 (controlled vocabulary)</h1>')
body.append('<div class="meta-bar">V3 구조 매칭이 단순 layout 일치가 아니라 <strong>의미 + 시각 의도</strong>로 '
'프레임을 구분하도록 하는 어휘 정의. 이 vocab 이 없으면 "3열 병렬" 같은 같은 layout 안에서 '
'다른 콘텐츠의 프레임이 뒤섞여 구분 불가.</div>')
body.append('<h2>content_affinity — 12종 (의미 축, 콘텐츠 성격)</h2>')
body.append('<table><thead><tr><th style="width:22%">값</th><th style="width:32%">의미</th><th>예시 (MDX 또는 Frame)</th></tr></thead><tbody>')
affinity_items = [
('concept_definition', '용어/개념의 정의', '"BIM이란", "DX란" · Frame 11 (BIM 활용 3분류 정의)'),
('concept_comparison', '2+ 개념의 차이점 비교', '"BIM vs DX" · Frame 18'),
('goal_axes', '목표의 핵심 축 나열 (N개)', '"DX의 궁극적 목표 3축" · Frame 12 (안전/품질/생산성)'),
('persona_benefit', '주체별 혜택/기대효과', '"발주자/설계자/시공자 기대효과" · Frame 14'),
('process_steps', '순서 있는 단계 (1→2→3)', '"1단계 계획 → 2단계 설계"'),
('before_after_change', '전/후 상태 전환', '"AS-IS → TO-BE" · Frame 29'),
('capability_requirements', '요구 역량/조건/도구', '"필수조건", "S/W 필수" · Frame 13, 20'),
('comparative_matrix', 'N개 × M축 비교표', '"관점별 다축 대비" · Frame 18, 23'),
('stakeholder_roles', '주체별 역할/책임/관계', '"정부 주도 / 민간 수행"'),
('policy_requirements', '정책/제도/거버넌스 틀', '"정부주도 BIM 도입" · Frame 27'),
('tool_ecosystem', '도구/SW 생태계', '"Revit, Navisworks 등"'),
('interrelation', 'N개 요소의 상호관계/순환', '"용어간 상호관계" · Frame 12 cycle-3way'),
]
for v, m, e in affinity_items:
body.append(f'<tr><td><code>{v}</code></td><td>{m}</td><td>{e}</td></tr>')
body.append('</tbody></table>')
body.append('<h2>structure_intent — 9종 (시각 의도, 레이아웃이 전달하는 방식)</h2>')
body.append('<table><thead><tr><th style="width:22%">값</th><th style="width:32%">의미</th><th>대표 layout</th></tr></thead><tbody>')
intent_items = [
('binary_compare', '2개 항목 대비', 'compare-2col, table-2col, cards-3-compare'),
('multi_parallel', 'N개 항목 병렬 나열', '3col-parallel, cards-3-header, cards-4-grid'),
('hierarchy', '상위-하위 관계 (계층)', 'central-5-goals, radial-diagram-5'),
('sequence', '순서 있는 흐름', 'list-numbered, split-panel-numbered'),
('state_transition', '전/후 상태 전환 대비', 'compare-2banner-top-2col-bottom'),
('cycle_interrelation', '순환/상호관계', 'cycle-3way, circular-nodes'),
('matrix_coverage', '다차원 커버리지', 'compare-rows, table-3col, paired-rows-2x2'),
('persona_mapping', '주체별 매핑', 'persona-3col, 2col-paired'),
('singleton_emphasis', '단일 강조', 'diagram-labels, side-card'),
]
for v, m, e in intent_items:
body.append(f'<tr><td><code>{v}</code></td><td>{m}</td><td><code>{e}</code></td></tr>')
body.append('</tbody></table>')
body.append('<h2>alternative_patterns — 대안 레이아웃 관계</h2>')
body.append('<p style="font-size:9pt">각 프레임이 "이 내용을 못 담을 때 어떤 다른 layout 이 대체 가능한가" 를 '
'명시. 기존 방식은 수작업 호환도 테이블(<code>_COMPAT</code>, 커버리지 26%)이었고, '
'새 스키마는 프레임별 <code>alternative_patterns</code> 필드로 파생.</p>')
body.append('<table><thead><tr><th style="width:25%">예시 프레임</th><th style="width:25%">기본 layout</th><th>대안 패턴</th></tr></thead><tbody>')
body.append('<tr><td>Frame 12 · 건설산업 목표</td><td><code>cycle-3way</code></td>'
'<td><code>3col-parallel</code> (0.7, 수동 시드 — 순환 대신 평면 병렬), '
'<code>circular-nodes</code> (0.6, 수동 시드 — radial 대안)</td></tr>')
body.append('<tr><td>Frame 18 · BIM vs DX</td><td><code>compare-rows</code></td>'
'<td><code>table-2col</code>, <code>compare-2col</code>, <code>2col-paired</code> '
'(파생 — 2-way 비교 계열)</td></tr>')
body.append('<tr><td>Frame 29 · Process 혁신</td><td><code>compare-2banner-top-2col-bottom</code></td>'
'<td><code>compare-2col</code>, <code>table-2col</code>, <code>2col-paired</code> (파생)</td></tr>')
body.append('</tbody></table>')
body.append('<h2>왜 이 vocab 이 필요한가 — 원인/효과</h2>')
body.append('<table><thead><tr><th style="width:40%">이 축이 없으면 발생하는 문제</th><th>이 축이 해결하는 것</th></tr></thead><tbody>')
body.append('<tr><td>"3열 병렬" 프레임끼리 구분 불가 — Frame 12 (목표 3축) 와 Frame 20 (S/W 필수 3관점) 둘 다 3열인데 의미 다름</td>'
'<td><code>content_affinity</code> 가 <code>goal_axes</code> vs <code>capability_requirements</code> 로 구분</td></tr>')
body.append('<tr><td>"compare-rows" 와 "table-2col" 이 다른 layout 처럼 보이지만 실제론 같은 2-way 비교</td>'
'<td><code>alternative_patterns</code> 에 명시 — 자동 대체 가능</td></tr>')
body.append('<tr><td>"혁신", "전환" 같은 단어가 과매칭 — 거의 모든 MDX 에 <code>state_transition</code> 붙음</td>'
'<td>STRONG 패턴(AS-IS/TO-BE/이중 변환) 없이는 인정 안 함 — validator 강화</td></tr>')
body.append('</tbody></table>')
return html_wrap('첨부 4 — 구조 매칭 키워드', ''.join(body))
# ============================================================
# ATTACH 05 — fit-v2 샘플 3건
# ============================================================
def build_attach05(v1, v2, v3_r5, v4_r2, descriptions):
body = []
body.append('<h1>첨부 5 — fit-v2 적합 샘플</h1>')
body.append('<div class="meta-bar">단순 구조 매칭이 아닌 template-fit-v2 판정이 필요한 이유를 3 가지 '
'상황(V1 강함 / V1 약함 / V1 강함 but 재구성 필요) 으로 보여줌.</div>')
def render_case(sid, case_label, analysis):
title, raw = extract_mdx_raw(sid)
excerpt = mdx_excerpt(raw, 120)
v1_sec = v1['mdx_sections'][sid]
answer = v1_sec.get('answer_frame_number')
v1_top = v1_sec['rank_by_matching_score'][0]
v4_top = sorted(v4_r2['mdx_sections'][sid]['v4_r2_judgments'], key=lambda x: x['v4_r2_rank'])[0]
lines = []
lines.append('<div class="avoid-break">')
lines.append(f'<h2>{case_label}{sid} · {title}</h2>')
lines.append(f'<div class="mdx-card"><div class="mdx-excerpt">{excerpt}</div></div>')
lines.append('<table><thead><tr><th style="width:25%">축</th><th>값</th></tr></thead><tbody>')
lines.append(f'<tr><td>V1 점수 (키워드)</td><td><code>{v1_top["matching_score"]:.3f}</code> → Frame {v1_top["frame_number"]}</td></tr>')
lines.append(f'<tr><td>V4 1위</td><td>Frame {v4_top["frame_number"]} conf <code>{v4_top["confidence"]:.3f}</code> '
f'<span class="label-badge label-{v4_top["label"]}">{v4_top["label"]}</span></td></tr>')
lines.append(f'<tr><td>V4 axes 분해</td><td>'
f'anchor <code>{v4_top["axes"]["anchor"]}</code> · '
f'cardinality <code>{v4_top["axes"]["cardinality"]}</code> · '
f'relation <code>{v4_top["axes"]["relation"]}</code> · '
f'slot <code>{v4_top["axes"]["slot"]}</code> · '
f'content <code>{v4_top["axes"]["content"]}</code> · '
f'penalty <code>{v4_top["penalty"]}</code>'
f'</td></tr>')
lines.append('</tbody></table>')
# V4 top1 이미지
fn = v4_top['frame_number']
is_ans = (answer is not None and fn == answer)
inner = (f'<img src="{img_uri(fn)}" class="frame-thumb" style="max-width:60mm;margin:0 auto"/>'
f'<div class="frame-label">Frame {fn}{"" if not is_ans else " 🎯 (정답)"}</div>'
f'<div class="frame-desc">{descriptions.get(fn, "")}</div>')
if is_ans:
inner = f'<div class="answer-box" style="display:inline-block">{inner}</div>'
lines.append(f'<div style="text-align:center;margin:4pt 0">{inner}</div>')
lines.append(f'<div class="{analysis["note_type"]}">{analysis["note"]}</div>')
lines.append('</div>')
return ''.join(lines)
# Case 1: V1 강함, V4 use_as_is — V4 가 V1 을 확인
body.append(render_case('01-2', '사례 1 — V1 강함 + V4 use_as_is (사용 가능 확인)', {
'note_type': 'ok-note',
'note': ('<strong>해석:</strong> V1 점수 <code>0.937</code> 으로 매우 강함. '
'V4 axes 도 모두 1.0 에 가깝고 label <span class="label-badge label-use_as_is">use_as_is</span>. '
'fit-v2 의 multi-axis 판정이 "이 프레임은 그대로 써도 된다" 를 명확히 확증. '
'V1 점수만 있어도 맞지만, V4 의 reject 여부 확인이 있어야 <strong>실사용 보장</strong>.'),
}))
# Case 2: V1 약함, V4 use_as_is — V4 가 V1 교정
body.append(render_case('03-1', '사례 2 — V1 약함 + V4 use_as_is (V4 가 교정)', {
'note_type': 'ok-note',
'note': ('<strong>해석:</strong> V1 점수 <code>0.598</code> 로 경계 이하(&lt; 0.7). 키워드만 보면 '
'애매함. V4 의 content_affinity (<code>capability_requirements</code> 매칭) + '
'cardinality + relation + slot 종합 판정으로 Frame 13 이 <code>use_as_is</code> 로 확정. '
'fit-v2 가 <strong>V1 의 약한 신호를 보강</strong>해 정답에 도달.'),
}))
# Case 3: V1 강함, V4 reject — 쓸 수 없다는 신호
body.append(render_case('02-2.2', '사례 3 — V1 강함 + V4 reject (재구성 필요 신호)', {
'note_type': 'fail-note',
'note': ('<strong>해석:</strong> V1 점수 <code>0.840</code> 강하고 1위 Frame 14 는 정답 (사용자 검수). '
'그러나 V4 confidence 는 <code>0.293</code> → <span class="label-badge label-reject">reject</span>. '
'axes 분해에서 anchor 와 content 는 맞지만 cardinality/relation/slot 의 점수가 낮음. '
'즉 <strong>"정답으로 지목됐지만 그대로 쓰기엔 구조가 부족"</strong>. '
'fit-v2 가 <strong>점수 1위 ≠ 실사용 가능</strong>을 드러내는 핵심 사례. '
'단순 구조 매칭 (V3) 으로는 이 신호를 잡을 수 없음.'),
}))
body.append('<div class="note">⇒ fit-v2 는 <strong>정답 맞추기가 아니라 실사용 판정</strong>. '
'이것이 V3 단순 구조 매칭보다 필요한 이유.</div>')
return html_wrap('첨부 5 — fit-v2 샘플', ''.join(body))
# ============================================================
# ATTACH 06 — Figma DB 형태 (기존 SAMPLE 4)
# ============================================================
def build_attach06(ontology_final_r2, descriptions):
templates = ontology_final_r2['templates_v2']
fid_12 = None
for fid, e in templates.items():
if e.get('short_id') == '12':
fid_12 = fid
break
e = templates[fid_12]
vp = e['visual_pattern']
aff = e['content_affinity']
intent = e['structure_intent_v2']
alts = e.get('alternative_patterns', [])
slots = e.get('slots', [])
suits = e.get('suits', [])
not_suits = e.get('not_suits', [])
body = []
body.append('<h1>첨부 6 — Figma DB 정리 방안 (Frame 12 예시)</h1>')
body.append('<div class="meta-bar">템플릿-fit 스키마 기반. 32 프레임 + BEPs 외 디자인들을 동일 스키마로 정리.</div>')
body.append('<div class="flex-grid avoid-break"><div class="flex-row">')
body.append('<div class="flex-cell" style="width:40%">')
body.append(f'<img src="{img_uri(12)}" class="frame-thumb" style="max-width:72mm"/>')
body.append(f'<div class="frame-label" style="margin-top:3pt">Frame 12 · {e["source"]["title"]}</div>')
body.append(f'<div class="frame-desc" style="font-size:8.5pt">{descriptions.get(12, "")}</div>')
body.append('</div>')
body.append('<div class="flex-cell" style="width:60%;padding-left:8pt">')
body.append('<h3 style="margin-top:0">visual_pattern</h3>')
body.append('<table>')
body.append(f'<tr><td style="width:35%"><code>layout</code></td><td><code>{vp["layout"]}</code></td></tr>')
body.append(f'<tr><td><code>family</code></td><td><code>{vp.get("family")}</code></td></tr>')
body.append(f'<tr><td><code>relation_type</code></td><td><code>{vp.get("relation_type")}</code></td></tr>')
card = vp.get("cardinality", {})
body.append(f'<tr><td><code>cardinality</code></td><td>ideal {card.get("ideal")} / min {card.get("min")} / max {card.get("max")}</td></tr>')
body.append('</table>')
body.append('</div></div></div>')
body.append('<h2>content_affinity · structure_intent · alternative_patterns</h2>')
body.append('<table>')
body.append(f'<tr><td style="width:28%"><code>content_affinity.primary</code></td><td><code>{aff["primary"]}</code></td></tr>')
body.append(f'<tr><td><code>content_affinity.secondary</code></td><td>' +
', '.join(f'<code>{s}</code>' for s in aff.get('secondary', []) or ['(없음)']) + '</td></tr>')
body.append(f'<tr><td><code>structure_intent.primary</code></td><td><code>{intent["primary"]}</code></td></tr>')
body.append(f'<tr><td><code>structure_intent.secondary</code></td><td>' +
', '.join(f'<code>{s}</code>' for s in intent.get('secondary', []) or ['(없음)']) + '</td></tr>')
body.append('</table>')
body.append('<h3>alternative_patterns</h3>')
body.append('<table><thead><tr><th>pattern</th><th>conf</th><th>source</th><th>reason</th></tr></thead><tbody>')
for a in alts:
body.append(f'<tr><td><code>{a["pattern"]}</code></td><td>{a["confidence"]}</td>'
f'<td><code>{a.get("source", "derived")}</code></td><td>{a["reason"]}</td></tr>')
body.append('</tbody></table>')
body.append('<h2>slots · suits · not_suits</h2>')
body.append('<div class="flex-grid"><div class="flex-row">')
body.append('<div class="flex-cell" style="width:55%">')
body.append('<h3>slots</h3>')
body.append('<table><thead><tr><th>id</th><th>type</th><th>required</th></tr></thead><tbody>')
for s in slots:
body.append(f'<tr><td><code>{s["id"]}</code></td><td>{s.get("type")}</td>'
f'<td>{s.get("required", False)}</td></tr>')
body.append('</tbody></table></div>')
body.append('<div class="flex-cell" style="width:45%;padding-left:6pt">')
body.append('<h3>suits · not_suits</h3>')
body.append('<div style="font-size:8.5pt"><strong>suits:</strong><ul style="padding-left:14pt;margin:2pt 0">')
for s in suits:
body.append(f'<li>{s}</li>')
body.append('</ul><strong>not_suits:</strong><ul style="padding-left:14pt;margin:2pt 0">')
for s in not_suits:
body.append(f'<li>{s}</li>')
body.append('</ul></div></div>')
body.append('</div></div>')
body.append('<h2 class="page-break">32 프레임 분포 + 정리 상태</h2>')
aff_counter = Counter(t['content_affinity']['primary'] for t in templates.values())
intent_counter = Counter(t['structure_intent_v2']['primary'] for t in templates.values())
body.append('<div class="flex-grid"><div class="flex-row">')
body.append('<div class="flex-cell" style="width:50%">')
body.append('<h3>content_affinity 분포</h3>')
body.append('<table><thead><tr><th>primary</th><th style="width:20%">수</th></tr></thead><tbody>')
for k, c in aff_counter.most_common():
body.append(f'<tr><td><code>{k}</code></td><td>{c}</td></tr>')
body.append('</tbody></table></div>')
body.append('<div class="flex-cell" style="width:50%;padding-left:8pt">')
body.append('<h3>structure_intent 분포</h3>')
body.append('<table><thead><tr><th>primary</th><th style="width:20%">수</th></tr></thead><tbody>')
for k, c in intent_counter.most_common():
body.append(f'<tr><td><code>{k}</code></td><td>{c}</td></tr>')
body.append('</tbody></table></div>')
body.append('</div></div>')
meta = ontology_final_r2['meta']
mo = len(meta.get('manual_overrides', []))
rv = len(meta.get('review_queue', []))
res = len(meta.get('resolved_review_items', []))
body.append(f'<div class="note">총 <strong>32</strong> 프레임. AI 라벨 초안 → 키워드 튜닝 → '
f'사용자 검토 루프 → 수동 오버라이드 <strong>{mo}</strong> 건 / '
f'review_queue 잔여 <strong>{rv}</strong> 건 / resolved <strong>{res}</strong> 건. '
f'향후 BEPs 외 디자인 추가 시 동일 스키마로 확장. 테스트 섹션 늘려가며 조정 필요.</div>')
return html_wrap('첨부 6 — Figma DB 정리 방안', ''.join(body))
# ============================================================
# 메인
# ============================================================
def main():
v1 = yaml.safe_load((HERE / 'mdx_matching_result.yaml').read_text(encoding='utf-8'))
v2 = yaml.safe_load((HERE / 'v2_semantic_rerank_result.yaml').read_text(encoding='utf-8'))
v3_r5 = yaml.safe_load((HERE / 'v3_structure_rerank_r5_result.yaml').read_text(encoding='utf-8'))
v4_r2 = yaml.safe_load((HERE / 'v4_template_fit_r2_result.yaml').read_text(encoding='utf-8'))
normalized = yaml.safe_load((HERE / 'normalized_text_tokens.yaml').read_text(encoding='utf-8'))
auto = yaml.safe_load((HERE / 'auto_anchor_candidates.yaml').read_text(encoding='utf-8'))
ontology_r2 = yaml.safe_load((HERE / 'structure_ontology_v2_final_r2.yaml').read_text(encoding='utf-8'))
descriptions = build_frame_descriptions(auto)
MEETING_BRIEF.write_text(build_meeting_brief(), encoding='utf-8')
ATTACH_01.write_text(build_attach01(v1, descriptions), encoding='utf-8')
ATTACH_02.write_text(build_attach02(v1, normalized, descriptions), encoding='utf-8')
ATTACH_03.write_text(build_attach03(v1, v2, v3_r5, v4_r2, descriptions), encoding='utf-8')
ATTACH_04.write_text(build_attach04(), encoding='utf-8')
ATTACH_05.write_text(build_attach05(v1, v2, v3_r5, v4_r2, descriptions), encoding='utf-8')
ATTACH_06.write_text(build_attach06(ontology_r2, descriptions), encoding='utf-8')
print('=' * 70)
print('회의 자료 7장 생성 완료 (A4 규격, base64 임베딩)')
print('=' * 70)
for p in [MEETING_BRIEF, ATTACH_01, ATTACH_02, ATTACH_03, ATTACH_04, ATTACH_05, ATTACH_06]:
size_kb = p.stat().st_size / 1024
print(f' {p.name:40} {size_kb:>8.0f} KB')
if __name__ == '__main__':
main()