"""Pipeline Step 14 — 단일 샘플 (MDX 01-2 × V1 Top-3 프레임) 제대로 정리. 사용자 지시: 1. 1 MDX 샘플만 집중 (01-2) 2. Top-3 프레임 모두 같은 포맷 (정답 포함) 3. MDX 제목 "DX와 BIM의 구분" 팝업 내용 기준 (섹션 전체 아님) 4. 이미지:텍스트 5:5 레이아웃 5. 1-element 세트는 skip (의미 없는 "세트") 6. 실제 데이터 정리 (내가 지어내지 않음) 출력: SAMPLE_01_2_V1_MATCHING.html (A4, Top-3 포함) """ import base64 import re from pathlib import Path import yaml HERE = Path(__file__).parent PNG_DIR = (HERE / '..' / '..' / 'data' / 'figma_previews').resolve() OUT_PATH = HERE / 'SAMPLE_01_2_V1_MATCHING.html' SID = '01-2' POPUP_TITLE = 'DX와 BIM의 구분' POPUP_DESC = ( '01.mdx §2 "용어간 상호관계" 의 펼침 팝업. ' 'BIM 과 DX 를 범위·S/W·프로세스·성과품·활용·확장성·수행개념·수행주체·' '발주처·설계사·시공사 등 12 가지 관점에서 행별로 비교한 표.' ) POPUP_EXCERPT_LINES = [ 'BIM ≪ DX (Engineering + Management 통합) | 범위 | Only 3D (형상 구현 중심)', '제작·운영 (상용+전용 40~80개, Rhino, Sketchup, Blender...) | S/W | 모델 제작용 상용 SW (Revit, Civil 3D, Navisworks, Autocad)', '근본적 문제의식을 통한 개선 | 프로세스 | 기존 2D 설계 방식 유지', '공학 정보 및 콘텐츠 연계 / 도면, 수량, 시공계획 등 일식 | 성과품 | 3D 모델 중심 / 기존 성과품 유지', ] def img_uri(fn): p = PNG_DIR / f'{fn:02d}.png' if not p.exists(): return '' return 'data:image/png;base64,' + base64.b64encode(p.read_bytes()).decode('ascii') def trim_list(items, max_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}개)' A4_CSS = """ @page { size: A4 portrait; margin: 12mm; } * { 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: 9pt; line-height: 1.4; } @media screen { body { max-width: 210mm; margin: 10mm auto; box-shadow: 0 4px 24px rgba(0,0,0,0.12); padding: 12mm; background: #fff; } } @media print { .match-block { page-break-inside: avoid; } } h1 { font-size: 13pt; border-bottom: 2pt solid #2563eb; padding-bottom: 3pt; margin: 0 0 4pt 0; color: #0f172a; } h2 { font-size: 10pt; color: #1e293b; margin: 6pt 0 3pt; padding: 2pt 5pt; background: #e0e7ff; border-left: 3pt solid #0a6; border-radius: 2pt; } .meta-bar { color: #64748b; font-size: 8pt; font-style: italic; margin-bottom: 4pt; } .mdx-card { background: #f8fafc; border: 0.5pt solid #cbd5e1; border-radius: 3pt; padding: 5pt 8pt; margin: 3pt 0 6pt; } .mdx-card .title { font-weight: 700; color: #0f172a; font-size: 9.5pt; margin-bottom: 2pt; } .mdx-card .popup-label { display: inline-block; padding: 1pt 5pt; background: #eef2ff; color: #3730a3; border-radius: 8pt; font-size: 7.5pt; font-weight: 700; margin-left: 4pt; vertical-align: middle; } .mdx-card .desc { color: #475569; font-size: 8pt; margin: 2pt 0; } .mdx-card ul { margin: 2pt 0 0 0; padding-left: 12pt; font-size: 8pt; color: #475569; line-height: 1.45; } .mdx-card li { margin: 1pt 0; } /* 5:5 레이아웃 — 이미지 | 텍스트 */ .match-block { display: table; width: 100%; border-radius: 3pt; padding: 5pt; margin: 4pt 0; border: 0.7pt solid #cbd5e1; background: #fafafa; } .match-block.answer { border: 1.5pt solid #dc2626; background: #fffbeb; } .match-row { display: table-row; } .match-image { display: table-cell; width: 50%; vertical-align: top; padding-right: 6pt; } .match-image img { width: 100%; max-width: 84mm; border: 0.5pt solid #94a3b8; border-radius: 3pt; display: block; } .match-image .frame-label { margin-top: 3pt; font-weight: 700; color: #2563eb; font-size: 9.5pt; } .match-image .frame-label.answer { color: #dc2626; } .match-image .frame-desc { font-size: 7.5pt; color: #64748b; margin-top: 1pt; line-height: 1.3; } .match-detail { display: table-cell; width: 50%; vertical-align: top; padding-left: 6pt; border-left: 0.5pt dashed #cbd5e1; } .score-row { font-size: 10pt; font-weight: 700; color: #0f172a; margin-bottom: 3pt; } .score-row code { background: #fff3cd; color: #78350f; padding: 1pt 5pt; border-radius: 2pt; font-size: 9.5pt; } .kw-line { margin: 3pt 0; font-size: 8pt; line-height: 1.4; } .kw-line strong { color: #0f172a; font-size: 8.5pt; } .hit-chip { color: #065f46; background: #d1fae5; padding: 1pt 4pt; border-radius: 2pt; font-size: 7.5pt; display: inline-block; margin: 0.5pt 0; } .miss-chip { color: #991b1b; background: #fee2e2; padding: 1pt 4pt; border-radius: 2pt; font-size: 7.5pt; display: inline-block; margin: 0.5pt 0; } .note { background: #fef3c7; border-left: 3pt solid #f59e0b; padding: 4pt 7pt; margin: 5pt 0; font-size: 8pt; color: #78350f; border-radius: 2pt; } .formula-box { background: #eff6ff; border: 0.7pt solid #93c5fd; border-radius: 3pt; padding: 6pt 10pt; margin: 0 0 6pt 0; font-size: 9pt; color: #1e3a8a; line-height: 1.5; } .formula-box code { background: #dbeafe; color: #1e40af; padding: 1pt 4pt; border-radius: 2pt; font-size: 8.5pt; } .calc-line { font-size: 8pt; color: #475569; margin-top: 3pt; font-family: ui-monospace, monospace; } .calc-line code { background: #fff3cd; color: #78350f; padding: 0 3pt; border-radius: 2pt; font-size: 8pt; } """ FRAME_TITLES = { # 01-2 Top-3 프레임의 설명 (descriptions 기반 수동 캐시) 18: 'BIM과 DX의 이해 — 다면 비교 표 (compare-rows)', 27: '건설산업의 고부가가치화 — central-split-synthesis', 11: '시공단계 BIM 모델·정보 활용 구분 — cards-3-category', } def render_frame_block(rank, rank_row, detail, answer_num): """단일 프레임 매칭 블록 (5:5 이미지/텍스트).""" fn = rank_row['frame_number'] score = rank_row['matching_score'] is_answer = (fn == answer_num) # 핵심 standalone = detail['standalone'] s_hit = sorted(standalone['hit']) s_miss = sorted([t for t in standalone['total'] if t not in standalone['hit']]) # 세트 (1-element 제외) groups = detail['keyword_group']['groups'] hit_sets = [] miss_sets = [] for g in sorted(groups, key=lambda x: -x.get('coverage', 0)): kws = list(g.get('keywords', [])) if len(kws) < 2: continue if g.get('coverage', 0) >= 0.5: hit_sets.append(kws) else: miss_sets.append(kws) # 연관 related = detail['related'] r_hit_list = sorted(related['hit']) r_miss_ct = related['total_count'] - related['hit_count'] frame_desc = FRAME_TITLES.get(fn, f'Frame {fn}') rank_label = {1: '1순위', 2: '2순위', 3: '3순위'}.get(rank, f'{rank}순위') answer_badge = ' 🎯 정답' if is_answer else '' box_class = 'match-block answer' if is_answer else 'match-block' label_class = 'frame-label answer' if is_answer else 'frame-label' html = [] html.append(f'
{score:.3f}'
+ (f' (최대 {ceiling:.2f})' if ceiling < 1.0 else '')
+ '{s_score_val:.3f} + '
f'0.50 × {g_score_val:.3f} + '
f'0.20 × {r_score_val:.3f}0.30 × 핵심 키워드 hit 비율 + '
'0.50 × 키워드 세트 평균 coverage + '
'0.20 × 연관 키워드 hit 비율')
html_parts.append('