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

596 lines
32 KiB
Python
Raw 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 — 회의용 샘플 HTML 4개 생성 (A4 규격).
산출물:
SAMPLE_01_V1_KEYWORD.html — V1 키워드 매칭 (MDX 섹션별)
SAMPLE_02_V1_V4_CASCADE.html — V1~V4 캐스케이드 결과
SAMPLE_03_SCHEMA_STACK.html — 스키마 누적 설계 (쌓아간 이유)
SAMPLE_04_FIGMA_DB.html — Figma DB 최종 형태 (Frame 12 예시)
A4 포맷 (@page size + print CSS).
이미지는 base64 임베딩 (파일 독립).
"""
import base64
import re
from collections import Counter
from pathlib import Path
import yaml
HERE = Path(__file__).parent
PNG_DIR = (HERE / '..' / '..' / 'data' / 'figma_previews').resolve()
MEETING_BRIEF = HERE / 'MEETING_BRIEF.html'
SAMPLE_01 = HERE / 'SAMPLE_01_V1_KEYWORD.html'
SAMPLE_02 = HERE / 'SAMPLE_02_V1_V4_CASCADE.html'
SAMPLE_03 = HERE / 'SAMPLE_03_SCHEMA_STACK.html'
SAMPLE_04 = HERE / 'SAMPLE_04_FIGMA_DB.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):
import sys
sys.path.insert(0, str(HERE))
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]
title = section[0].lstrip('#').strip()
return title, '\n'.join(section[1:])
def mdx_excerpt(raw, max_chars=180):
"""첫 일반 문단 추출."""
lines = raw.split('\n')
buf = []
for ln in lines:
s = ln.strip()
if not s:
if buf:
break
continue
if s.startswith(('#', '|', '<', '*', '-', ':', '```', '---', '>')):
continue
if re.match(r'^\d+\.\s', s):
continue
buf.append(s)
if sum(len(x) for x in buf) > max_chars:
break
text = ' '.join(buf)
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
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; }
.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; }
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; }
"""
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: 1장짜리 요약 (맨 앞)
# ============================================================
def build_meeting_brief():
body = []
body.append('<h1>MDX ↔ Figma 자동 매칭 — 현황 + Figma DB 제안</h1>')
body.append('<div class="meta-bar">첨부 샘플 4장과 함께 검토. 이 한 장이 전체 요약.</div>')
# Section 1: 지금까지
body.append('<h2>1. 지금까지 한 것</h2>')
body.append('''
<p style="margin:3pt 0">MDX 문서의 각 섹션에 가장 맞는 Figma 프레임을 자동 매칭하는 파이프라인.
텍스트 신호부터 시작해 의미·구조·적용 가능성을 <strong>단계적으로 쌓아가며</strong> 검증.</p>
<table style="margin-top:3pt">
<thead><tr><th style="width:14%">단계</th><th style="width:24%">추가 축</th><th style="width:30%">확인 포인트</th><th>결과</th></tr></thead><tbody>
<tr><td><strong>V1</strong> 키워드</td><td>3-layer 키워드 점수 (단독/묶음/연관)</td><td>기본 후보 필터 — 얼마나 맞는가, 어디서 한계가 드러나는가</td><td>TARGET 4/4 1위 정답. 단, 점수 0.7 미만 케이스(03-1 = 0.598)는 애매</td></tr>
<tr><td><strong>V2</strong> + 의미</td><td>MDX summary ↔ Frame content 의 ko-sroberta cosine</td><td>같은 의미인데 키워드가 다른 경우 잡히나</td><td>Holdout 02-2.1: V1 비합리 → V2 합리 (교정 사례 확인)</td></tr>
<tr><td><strong>V3</strong> + 구조</td><td>layout / cardinality / relation / alternative patterns</td><td>구조적으로 담을 수 있는 프레임인가</td><td>기존 <code>_COMPAT</code> 대체. 3-way 순환(Frame 12) 같은 고아 패턴도 대안 연결</td></tr>
<tr><td><strong>V4</strong> + 적용 가능성</td><td>template-fit 멀티게이트 + penalty (adaptation / not_suits)</td><td>정답 맞추기가 아니라 <strong>실제 쓸 수 있는가</strong></td><td>Holdout 합리성 평가 V4 <strong>78%</strong> (V1/V2 33%, V3 10%) — 가장 안정</td></tr>
</tbody></table>
''')
# Section 2: 핵심 발견
body.append('<h2>2. 핵심 발견 — 운영 로직</h2>')
body.append('''
<p style="margin:3pt 0">V1 점수로 가지를 나누면 실제로 깔끔한 라우팅이 나옴.</p>
<table style="margin-top:3pt"><thead><tr>
<th style="width:22%">V1 점수</th><th style="width:38%">의사결정</th><th>사례</th>
</tr></thead><tbody>
<tr><td><code>≥ 0.70</code> 강함</td><td>V1 top1 그대로 사용 · V4 는 <strong>실사용 가능성</strong> 검증 역할 (use_as_is/reject)</td><td>01-2 (0.937), 02-2.2 (0.840), 03-2 (0.705)</td></tr>
<tr><td><code>0.40 ~ 0.70</code> 약함</td><td>V4 가 재정렬/판정 — label 이 use_as_is / light_edit 이면 사용</td><td>03-1 (0.598) → V4 로 Frame 13 확정 ✓</td></tr>
<tr><td><code>&lt; 0.40</code> 매우 약함</td><td>V4 가 reject 가능성 높음 — <strong>기존 프레임 없음, 재구성 단계로</strong></td><td>Holdout 02-2.1 (0.246) → V4 reject ← 프레임 라이브러리에 없는 유형</td></tr>
</tbody></table>
''')
# Section 3: Figma DB 제안
body.append('<h2>3. 제안 — Figma DB 는 이렇게 정리</h2>')
body.append('''
<p style="margin:3pt 0">V4 가 쓸 수 있으려면 프레임마다 아래 필드 + 근거(evidence)가 필요.</p>
<table style="margin-top:3pt"><thead><tr>
<th style="width:24%">필드</th><th style="width:36%">무엇</th><th>왜</th>
</tr></thead><tbody>
<tr><td><code>visual_pattern</code></td><td>layout · family · axis · relation_type · cardinality</td><td>구조 매칭 기본 축</td></tr>
<tr><td><code>content_affinity</code></td><td>12종 enum (concept_definition / goal_axes / persona_benefit / before_after_change / capability_requirements / …)</td><td>같은 레이아웃이라도 <strong>의미가 다른 프레임을 구분</strong></td></tr>
<tr><td><code>structure_intent</code></td><td>9종 enum (binary_compare / multi_parallel / cycle_interrelation / state_transition / …)</td><td>시각적으로 전달하려는 의도 (내용과 직교)</td></tr>
<tr><td><code>alternative_patterns</code></td><td>이 레이아웃이 담지 못할 때의 대체 layout 목록 + confidence</td><td>"대안 찾기" — 고아 layout (cycle-3way 등) 도 연결</td></tr>
<tr><td><code>slots · suits · not_suits</code></td><td>편집 영역 + 적합/부적합 기준</td><td>template-fit 판정 근거</td></tr>
<tr><td><code>evidence · resolved_from_review_queue</code></td><td>라벨 근거 + 수동 검수 이력</td><td>SSOT 추적성 — 왜 이 라벨이 붙었는지 언제나 확인 가능</td></tr>
</tbody></table>
<p style="margin-top:4pt;color:#475569;font-size:8.5pt">현재 상태: 32 프레임 일괄 AI 라벨링 → 키워드 튜닝 3회 반복 →
수동 오버라이드 3건 (SSOT 확정) + review_queue 3건 잔여. 1 건 해제 (Frame 13).</p>
''')
# Footer: 샘플 목록
body.append('<h2>첨부 샘플 4장</h2>')
body.append('''
<table style="margin-top:3pt"><thead><tr>
<th style="width:8%">#</th><th style="width:32%">파일</th><th>내용</th>
</tr></thead><tbody>
<tr><td><strong>1</strong></td><td><code>SAMPLE_01_V1_KEYWORD.html</code></td><td>TARGET 4 섹션 × MDX 원문 요약 + 추출 키워드 + V1 Top-3</td></tr>
<tr><td><strong>2</strong></td><td><code>SAMPLE_02_V1_V4_CASCADE.html</code></td><td>TARGET 4 섹션 × V1/V2/V3/V4 단계별 Top-1 비교 + 라벨</td></tr>
<tr><td><strong>3</strong></td><td><code>SAMPLE_03_SCHEMA_STACK.html</code></td><td>왜 V1→V2→V3→V4 로 쌓았는가 (층별 해결 문제 + 대표 사례)</td></tr>
<tr><td><strong>4</strong></td><td><code>SAMPLE_04_FIGMA_DB.html</code></td><td>Figma DB 최종 형태 (Frame 12 전체 필드 예시 + 32 프레임 요약)</td></tr>
</tbody></table>
''')
return html_wrap('MDX ↔ Figma 매칭 — 현황 + Figma DB 제안', ''.join(body))
# ============================================================
# SAMPLE 1: V1 키워드 매칭
# ============================================================
def build_sample1(v1, normalized, descriptions):
body = []
body.append('<h1>샘플 1 — V1 키워드 매칭</h1>')
body.append('<div class="meta-bar">MDX 섹션에서 추출한 키워드로 32 Figma 프레임과 매칭. '
'각 MDX 의 Top-3 유사 프레임 + 점수.</div>')
for sid in TARGET_SIDS:
title, raw = extract_mdx_raw(sid)
excerpt = mdx_excerpt(raw)
tokens = normalized['mdx'][sid].get('unique_tokens', [])[:14]
v1_sec = v1['mdx_sections'][sid]
answer = v1_sec.get('answer_frame_number')
top3 = v1_sec['rank_by_matching_score'][:3]
body.append('<div class="avoid-break">')
body.append(f'<h2>{sid} · {title}</h2>')
body.append('<div class="mdx-card">')
body.append(f'<div class="mdx-excerpt">{excerpt}</div>')
body.append('<div style="margin-top:3pt">')
body.append('<strong style="font-size:8.5pt;color:#475569">추출 키워드:</strong> ')
for t in tokens:
body.append(f'<span class="kw-chip">{t}</span>')
body.append('</div>')
body.append('</div>')
body.append('<div class="flex-grid" style="margin-top:4pt"><div class="flex-row">')
for idx, r in enumerate(top3):
fn = r['frame_number']
is_ans = fn == answer
cell = '<div class="frame-cell flex-cell">'
body_html = (
f'<img src="{img_uri(fn)}" alt="{fn:02d}" class="frame-thumb"/>'
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, "")[:26]}</div>'
)
if is_ans:
body_html = f'<div class="answer-box">{body_html}</div>'
cell += body_html + '</div>'
body.append(cell)
body.append('</div></div>')
body.append('</div>')
body.append('<div class="note">정답 Frame 은 🎯 노란 박스. V1 은 "키워드 기반 후보 필터" 역할. '
'점수 0.7 이상일 때 신뢰 가능, 그 아래는 V4 까지 가야 교정됨.</div>')
return html_wrap('샘플 1 — V1 키워드 매칭', ''.join(body))
# ============================================================
# SAMPLE 2: V1~V4 캐스케이드
# ============================================================
def build_sample2(v1, v2, v3_r5, v4_r2, descriptions):
body = []
body.append('<h1>샘플 2 — V1~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 style="margin-top:3pt"><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)}" alt="{fn:02d}" 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>')
body.append('</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('샘플 2 — V1~V4 캐스케이드', ''.join(body))
# ============================================================
# SAMPLE 3: 스키마 누적 설계
# ============================================================
def build_sample3():
body = []
body.append('<h1>샘플 3 — V1에서 V4로의 스키마 누적 (쌓아간 이유)</h1>')
body.append('<div class="meta-bar">단일 축(키워드)로는 놓치는 신호를 각 층에서 보강. '
'각 층의 필드 / 해결하는 문제 / 입력 예시.</div>')
# 흐름도 (텍스트 기반)
body.append('<h2>누적 흐름</h2>')
body.append('''
<table style="border:none;margin:2pt 0"><tr>
<td style="border:none;text-align:center;padding:4pt 6pt;background:#dbeafe;color:#1e40af;font-weight:700;border-radius:3pt">V1<br><span style="font-size:8pt;font-weight:400">키워드</span></td>
<td style="border:none;font-size:12pt;color:#64748b;padding:0 3pt">→</td>
<td style="border:none;text-align:center;padding:4pt 6pt;background:#e0e7ff;color:#3730a3;font-weight:700;border-radius:3pt">V1 + V2<br><span style="font-size:8pt;font-weight:400">+ 의미</span></td>
<td style="border:none;font-size:12pt;color:#64748b;padding:0 3pt">→</td>
<td style="border:none;text-align:center;padding:4pt 6pt;background:#fef3c7;color:#92400e;font-weight:700;border-radius:3pt">V1 + V2 + V3<br><span style="font-size:8pt;font-weight:400">+ 구조</span></td>
<td style="border:none;font-size:12pt;color:#64748b;padding:0 3pt">→</td>
<td style="border:none;text-align:center;padding:4pt 6pt;background:#d1fae5;color:#065f46;font-weight:700;border-radius:3pt">V1 + V2 + V3 + V4<br><span style="font-size:8pt;font-weight:400">+ 적용 가능성</span></td>
</tr></table>
''')
# 층별 표
body.append('<h2>층별 상세</h2>')
body.append('<table><thead><tr>'
'<th style="width:10%">층</th>'
'<th style="width:22%">축 / 신호</th>'
'<th style="width:28%">해결하는 문제</th>'
'<th style="width:40%">대표 사례 (TARGET)</th>'
'</tr></thead><tbody>')
body.append('''
<tr>
<td><strong>V1</strong><br><span style="font-size:8pt;color:#64748b">키워드 baseline</span></td>
<td>3-layer: 단독 대표 키워드 / 키워드 묶음 / 연관 키워드<br><code>0.30·단독 + 0.50·묶음 + 0.20·연관</code></td>
<td>가장 빠른 후보 필터. 같은 용어를 다루는 프레임을 찾음. 점수가 강하면 그대로 사용 가능.</td>
<td><strong>01-2</strong> "용어간 상호관계" → Frame 18 (0.937) ✓ · <strong>약한 사례</strong> 03-1 (0.598) → V4 까지 가야 교정</td>
</tr>
<tr>
<td><strong>V2</strong><br><span style="font-size:8pt;color:#64748b">+ 의미</span></td>
<td>MDX summary ↔ Frame content 의 ko-sroberta cosine<br><code>V1 Top-K 내 재정렬</code></td>
<td>키워드가 달라도 내용/주제가 비슷한 프레임 식별. V1 이 놓치는 의미 유사성 보강.</td>
<td>Holdout <strong>02-2.1</strong>: V1 비합리 → V2 합리 (의미 축이 교정)</td>
</tr>
<tr>
<td><strong>V3</strong><br><span style="font-size:8pt;color:#64748b">+ 구조</span></td>
<td>MDX 구조(코드 추출) × Figma 구조(AI 산출)<br>content_affinity + structure_intent + alternative_patterns</td>
<td>같은 주제여도 구조적으로 담을 수 있는 프레임인지 판단. 비교형/병렬형/순환형 등 구분.</td>
<td>기존 <code>_COMPAT</code> 테이블 대체. 3-way 순환 (Frame 12) 은 3col-parallel 대안으로 명시.</td>
</tr>
<tr>
<td><strong>V4</strong><br><span style="font-size:8pt;color:#64748b">+ 적용 가능성</span></td>
<td>template-fit-v1 multi-gate<br>anchor + cardinality + relation + slot + content penalties</td>
<td>이 프레임을 <b>실제로 쓸 수 있는지</b> 최종 판정. use_as_is / light_edit / restructure / reject 4단계.</td>
<td><strong>02-2.2</strong>: V1 점수 0.840 (강함) + 정답 frame 1위 → 그러나 V4 <span class="label-badge label-reject">reject</span> ← "쓸 수 있는 프레임 아님" 신호</td>
</tr>
''')
body.append('</tbody></table>')
# 운영 로직
body.append('<h2>운영 로직 (관찰 기반)</h2>')
body.append('''
<table><thead><tr><th style="width:25%">V1 점수</th><th>의사결정</th></tr></thead><tbody>
<tr><td><code>≥ 0.70</code> 강함</td><td>V1 top1 사용 · V4 는 "실사용 가능성" 검증 (라벨만 참고)</td></tr>
<tr><td><code>0.40 ~ 0.70</code> 약함</td><td>V4 재정렬에 맡김 · label 이 use_as_is/light_edit 이면 사용</td></tr>
<tr><td><code>&lt; 0.40</code> 매우 약함</td><td>V4 가 reject 할 가능성 높음 → <b>기존 프레임 없음, 재구성 단계</b></td></tr>
</tbody></table>
''')
body.append('<div class="note">V1 만으로는 약한 케이스에서 오답. V4 만 바로 쓰면 '
'후보 풀이 너무 커짐. 그래서 V1(필터) + V2/V3(보강 신호) + V4(최종 판정) 캐스케이드가 실용적.</div>')
return html_wrap('샘플 3 — 스키마 누적 설계', ''.join(body))
# ============================================================
# SAMPLE 4: Figma DB 최종 형태 (Frame 12)
# ============================================================
def build_sample4(ontology_final_r2, descriptions):
templates = ontology_final_r2['templates_v2']
# Frame 12 찾기
fid_12 = None
for fid, e in templates.items():
if e.get('short_id') == '12':
fid_12 = fid
break
if not fid_12:
raise SystemExit("Frame 12 not found in ontology_final_r2")
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', [])
v2_meta = e.get('v2_meta', {})
body = []
body.append('<h1>샘플 4 — Figma DB 최종 형태 (Frame 12 예시)</h1>')
body.append('<div class="meta-bar">template-fit-v2 스키마. 한 프레임이 담고 있는 전체 필드 — '
'그림 / layout / 의미 축 (content_affinity) / 시각 축 (structure_intent) / '
'대안 관계 / 슬롯 / 적합·부적합 기준 / 라벨 이력.</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)}" alt="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> (원본 <code>{e["source"].get("original_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>')
# content_affinity
body.append('<h2>content_affinity (의미 축) — "어떤 콘텐츠 성격인가"</h2>')
body.append('<table>')
body.append(f'<tr><td style="width:20%"><strong>primary</strong></td><td><code>{aff["primary"]}</code></td></tr>')
body.append(f'<tr><td><strong>secondary</strong></td><td>' +
', '.join(f'<code>{s}</code>' for s in aff.get('secondary', []) or ['(없음)']) + '</td></tr>')
# evidence
evp = aff.get('evidence', {}).get('primary', {})
body.append(f'<tr><td><strong>evidence.primary</strong></td><td>source: <code>{evp.get("source")}</code>'
+ (f" · keywords: {evp.get('keywords')}" if evp.get('keywords') else '')
+ (f" · rule: <em>{evp.get('rule')}</em>" if evp.get('rule') else '')
+ f" · confidence: {evp.get('confidence')}</td></tr>")
body.append('</table>')
# structure_intent
body.append('<h2>structure_intent (시각 의도) — "어떻게 전달하는가"</h2>')
body.append('<table>')
body.append(f'<tr><td style="width:20%"><strong>primary</strong></td><td><code>{intent["primary"]}</code></td></tr>')
body.append(f'<tr><td><strong>secondary</strong></td><td>' +
', '.join(f'<code>{s}</code>' for s in intent.get('secondary', []) or ['(없음)']) + '</td></tr>')
body.append('</table>')
# alternative_patterns
body.append('<h2>alternative_patterns (대안 레이아웃) — "담지 못할 때 어떤 대체가 가능한가"</h2>')
body.append('<table><thead><tr><th>pattern</th><th>confidence</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>')
# slots
body.append('<h2>slots — 편집 가능한 영역</h2>')
body.append('<table><thead><tr><th>id</th><th>type</th><th>required</th><th>제약</th></tr></thead><tbody>')
for s in slots:
constraints = []
if 'max_chars' in s: constraints.append(f'max_chars={s["max_chars"]}')
if 'min_items' in s: constraints.append(f'min_items={s["min_items"]}')
if 'max_items' in s: constraints.append(f'max_items={s["max_items"]}')
body.append(f'<tr><td><code>{s["id"]}</code></td><td>{s.get("type")}</td>'
f'<td>{s.get("required", False)}</td><td>{", ".join(constraints) or "—"}</td></tr>')
body.append('</tbody></table>')
# suits / not_suits
body.append('<div class="flex-grid avoid-break"><div class="flex-row">')
body.append('<div class="flex-cell" style="width:50%">')
body.append('<h3>suits (적합 — 긍정 신호)</h3>')
body.append('<ul style="font-size:9pt;padding-left:14pt;margin:3pt 0">')
for s in suits:
body.append(f'<li>{s}</li>')
body.append('</ul></div>')
body.append('<div class="flex-cell" style="width:50%">')
body.append('<h3>not_suits (부적합 — 반대 신호)</h3>')
body.append('<ul style="font-size:9pt;padding-left:14pt;margin:3pt 0">')
for s in not_suits:
body.append(f'<li>{s}</li>')
body.append('</ul></div>')
body.append('</div></div>')
# resolved_from_review_queue (only for Frame 13... Frame 12 has manual_seed for alternatives)
if v2_meta.get('resolved_from_review_queue'):
prov = v2_meta['resolved_from_review_queue']
body.append('<h2>라벨 이력 (provenance)</h2>')
body.append(f'<div class="note">review_queue 에서 확정됨: '
f'{prov.get("decision_rationale", "")} · 확정 시점 {prov.get("confirmed_at")}</div>')
# 전체 32 프레임 요약
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 primary 분포</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 primary 분포</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']
total = len(templates)
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>{total}</strong> 프레임. '
f'수동 오버라이드 {mo} 건 (SSOT 확정) · review_queue 잔여 {rv} 건 · '
f'resolved {res} 건. AI 초안 → 키워드 튜닝 → 사용자 검토 → SSOT 확정 루프 적용.</div>')
return html_wrap('샘플 4 — 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')
SAMPLE_01.write_text(build_sample1(v1, normalized, descriptions), encoding='utf-8')
SAMPLE_02.write_text(build_sample2(v1, v2, v3_r5, v4_r2, descriptions), encoding='utf-8')
SAMPLE_03.write_text(build_sample3(), encoding='utf-8')
SAMPLE_04.write_text(build_sample4(ontology_r2, descriptions), encoding='utf-8')
print('=' * 70)
print('회의 자료 5장 생성 완료 (A4 규격)')
print('=' * 70)
for p in [MEETING_BRIEF, SAMPLE_01, SAMPLE_02, SAMPLE_03, SAMPLE_04]:
size_kb = p.stat().st_size / 1024
print(f' {p.name:35} {size_kb:>8.0f} KB')
if __name__ == '__main__':
main()