- 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>
596 lines
32 KiB
Python
596 lines
32 KiB
Python
"""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>< 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>< 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()
|