"""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'
') # Left: image 50% html.append('
') html.append(f'Frame {fn}') html.append(f'
Frame {fn}{answer_badge} ({rank_label})
') html.append(f'
{frame_desc}
') html.append('
') # Right: detail 50% html.append('
') # 점수 + 계산식 s_score_val = standalone['score'] g_score_val = detail['keyword_group'].get('score_avg', 0) r_score_val = related['score'] if 'score' in related else (related['hit_count'] / related['total_count'] if related['total_count'] else 0) ceiling = (0.30 if standalone['total_count'] > 0 else 0) + 0.50 + 0.20 html.append(f'
매칭점수 {score:.3f}' + (f' (최대 {ceiling:.2f})' if ceiling < 1.0 else '') + '
') html.append(f'
' f'= 0.30 × {s_score_val:.3f} + ' f'0.50 × {g_score_val:.3f} + ' f'0.20 × {r_score_val:.3f}
') # 핵심 html.append('
핵심 키워드
') html.append(f' (o) {trim_list(s_hit, 5)}
') html.append(f' (x) {trim_list(s_miss, 5)}') html.append('
') # 세트 html.append('
키워드 세트
') html.append(f' (o) {trim_sets(hit_sets, 3)}
') html.append(f' (x) {trim_sets(miss_sets, 2)}') html.append('
') # 연관 html.append('
연관 키워드
') html.append(f' (o) {trim_list(r_hit_list, 5)}
') html.append(f' (x) 미매칭 {r_miss_ct}개') html.append('
') html.append('
') return ''.join(html) def main(): v1 = yaml.safe_load((HERE / 'mdx_matching_result.yaml').read_text(encoding='utf-8')) v1_sec = v1['mdx_sections'][SID] answer = v1_sec['answer_frame_number'] top3 = v1_sec['rank_by_matching_score'][:3] html_parts = [] html_parts.append(f'' f'MDX 01-2 매칭 결과') # 공식 html_parts.append('
') html_parts.append('매칭점수 = ' '0.30 × 핵심 키워드 hit 비율 + ' '0.50 × 키워드 세트 평균 coverage + ' '0.20 × 연관 키워드 hit 비율') html_parts.append('
') # MDX html_parts.append('
') html_parts.append(f'
MDX 01-2 · {POPUP_TITLE}
') html_parts.append('') html_parts.append('
') # Top-3 프레임 for idx, r in enumerate(top3, start=1): fid = r['frame_id'] detail = v1_sec['per_frame_detail'][fid] html_parts.append(render_frame_block(idx, r, detail, answer)) html_parts.append('') OUT_PATH.write_text('\n'.join(html_parts), encoding='utf-8') print(f'생성: {OUT_PATH}') print(f' 절대경로: D:\\ad-hoc\\kei\\design_agent\\tests\\matching\\SAMPLE_01_2_V1_MATCHING.html') if __name__ == '__main__': main()