"""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 블록 진입/종료 (
, ,
, 등) if in_jsx_block: if s.endswith('}}>') or re.search(r'', s): in_jsx_block = False continue if re.match(r'^<(div|span|details|summary)\b', s): # 같은 줄에서 닫히면 skip only, 그렇지 않으면 block 진입 if not re.search(r'(/>|)\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'(?= 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""" {title} {body}""" # ============================================================ # MEETING BRIEF (업데이트 — 5-step narrative) # ============================================================ def build_meeting_brief(): body = [] body.append('

MDX ↔ Figma 프레임 자동 매칭 — 현황 및 제안

') body.append('
첨부 6장과 함께 검토. 이 한 장이 전체 요약.
') body.append('

1. 개요

') body.append('
    ') body.append('
  • CEL 각 섹션별 내용(MDX)에 가장 적합한 Figma 프레임을 매칭하는 프로세스 확인
  • ') body.append('
  • 실행 내용: Figma 디자인에서 텍스트 요소를 추출하여 MDX와의 유사도를 통한 매칭 실시
  • ') body.append('
') # 5-step narrative body.append('

2. 시행 단계

') body.append('
    ') body.append('''
  1. 기존 라이브러리/모델 적용 — tf-idf, sobert, BM25, cross-encoder 등 적용
    (*) 일반적인 단어, 동음이의/도메인 용어 부재 등의 요인으로 매칭 점수 낮거나 미매칭. 예: "전환"(일반) vs "디지털 전환"(DX) 혼동, BEPs 고유어(결과혁신/과정혁신 등) 학습 안 됨.
  2. ''') body.append('''
  3. AI로 디자인 분석하여 매칭
    (*) 없는 단어, 추론 단어 등의 요인으로 매칭 점수 낮게 나오며, 구조가 맞지 않음.
  4. ''') body.append('''
  5. 키워드 전처리 및 가중치를 통한 매칭 (첨부 1. 키워드 정리)
    (*) Figma에서 추출한 텍스트에서 해당 디자인의 핵심 키워드 / 세트 키워드 / 연관 키워드를 구분, MDX에서 해당 키워드를 통해 매칭 점수 정리 (첨부 2. 키워드 매칭)
    → 32개 디자인 중 적합한 프레임 매칭. 하지만 2~3순위 프레임을 활용하기엔 부적절.
    ⇒ BEPs에 없는 내용에 대한 디자인 매칭 시 구조에 적합하지 않은 디자인 매칭. (첨부 2. 키워드 매칭 — 한계)
  6. ''') body.append('''
  7. 키워드 매칭 + 의미 / 구조 / 적용 가능성을 통한 매칭 (첨부 3. 매칭 결과)
    (*) 매칭 결과 표
    → 구조 매칭 시, 내용에 기반하여 적합한 디자인과 매칭 (첨부 4. 구조 매칭 키워드)
    (*) 단순 구조 매칭이 아닌 fit-v2가 가장 적합 (첨부 5. 샘플)
  8. ''') body.append('''
  9. Figma 프레임별 추출 내용 및 정리 방안
    → 32개 프레임 외, BEPs에서의 디자인들을 프레임별로 추출 및 정리 (첨부 6. 정리 방안)
    → 테스트를 늘려가며 조금 더 조정할 필요
  10. ''') body.append('
') # 첨부 목차 body.append('

첨부 6장

') body.append('''
#파일내용
1ATTACH_01_KEYWORD_ORGANIZATION.htmlFigma 프레임별 3-layer 키워드 정리 (핵심/세트/연관)
2ATTACH_02_KEYWORD_MATCHING.htmlV1 키워드 매칭 결과 + 한계 사례 (BEPs 밖 콘텐츠)
3ATTACH_03_CASCADE_RESULT.htmlV1 키워드 → + 의미 → + 구조 → + 적용 가능성 단계별 결과
4ATTACH_04_STRUCTURE_VOCAB.html구조 매칭 키워드 정의 (content_affinity 12, structure_intent 9, alt_patterns)
5ATTACH_05_SAMPLES.htmlfit-v2 적합 샘플 3건 (V1 강함/약함/reject 각 사례)
6ATTACH_06_DB_ORGANIZATION.htmlFigma DB 형태 (Frame 예시 + 32 프레임 요약)
''') 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'
') badge = ' 🎯 정답' if is_answer else '' html.append(f'

Frame {fn}{badge} ' f'' f'({rank_label} · 매칭점수 {score:.3f})

') html.append('
') html.append('
') html.append(f'{fn:02d}') if frame_desc: html.append(f'
{frame_desc[:40]}
') html.append('
') html.append('
') # 핵심 (단독 대표 키워드): 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('
핵심 키워드
') html.append(f' (o) {trim_list(s_hit_list, MAX_INLINE_KW)}') html.append(f' (x) {trim_list(s_miss_list, MAX_INLINE_KW)}') html.append('
') # 세트 (키워드 묶음): 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('
키워드 세트
') html.append(f' (o) {trim_sets(hit_sets, MAX_INLINE_SETS)}') html.append(f' (x) {trim_sets(miss_sets, MAX_INLINE_SETS)}') html.append('
') # 연관 키워드: 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('
연관 키워드
') html.append(f' (o) {trim_list(r_hit_list, MAX_INLINE_KW)}') html.append(f' (x) 미매칭 {r_miss_ct}개') html.append('
') html.append('
') # close fm-detail html.append('
') # close fm-body html.append('
') # close frame-match return ''.join(html) def build_attach01(v1, descriptions): """6-page V1 매칭 상세 breakdown. 각 page 1 MDX 섹션 + Top-3 프레임.""" body = [] body.append('

첨부 1 — V1 키워드 매칭 상세

') body.append('
MDX 섹션마다 Top-3 프레임의 매칭 근거. ' '각 프레임의 핵심/세트/연관 키워드 중 어떤 것이 MDX 에 등장했는지 (o/x) 확인. ' 'TARGET 4 (매칭된 샘플) + Holdout 2 (매칭 안된 샘플) = 총 6 페이지.
') 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('
') page_num = idx + 1 body.append(f'
샘플 {page_num} / {total_pages}
') title, raw = extract_mdx_raw(sid) # MDX 요약 (2-3줄, 약 200자) excerpt = mdx_excerpt(raw, 220) tag = ('' if is_matched else '') body.append(f'

MDX {sid} · {title} {tag}

') body.append(f'
{excerpt}
') 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('

프레임별 매칭 breakdown (Top-3)

') 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('

첨부 2 — 키워드 매칭 결과 + 한계

') body.append('
MDX 섹션별 추출 키워드 × 32 프레임 키워드 매칭. 결과 + 2~3순위의 한계 / BEPs 밖 콘텐츠 한계.
') body.append('

매칭 공식

') body.append('') body.append('') body.append('') body.append('') body.append('
신호의미가중치
단독 대표 키워드특정 프레임을 혼자서도 강하게 가리키는 키워드 매칭0.30
세트 키워드 묶음프레임 원문 한 줄에서 함께 등장하는 키워드 조합 매칭0.50
연관 키워드참고 키워드 겹침 정도0.20
') body.append('

TARGET 4 섹션 결과

') 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('
') body.append(f'
{sid} · {title}
') body.append(f'
1위 점수 {top_score:.3f} ' f'[{strength}] · 1-2위 격차 {gap:.3f}
') body.append('
') for r in top3: fn = r['frame_number'] is_ans = fn == answer inner = (f'' f'
Frame {fn}{"" if not is_ans else " 🎯"}
' f'
{r["matching_score"]:.3f}
' f'
{descriptions.get(fn, "")[:24]}
') if is_ans: inner = f'
{inner}
' body.append(f'
{inner}
') body.append('
') body.append('

한계 사례

') body.append('

한계 1. 2~3순위 프레임이 부적절

') body.append('
1위는 대체로 맞지만, V1 점수만으로는 2~3위가 같은 수준의 대안인지 ' '단순 키워드 중복으로 올라왔는지 구분 어려움. 예: 01-2 의 2위 Frame 27 (score 0.392) 은 ' '키워드는 겹치지만 구조(central-split)는 부적합.
') body.append('

한계 2. BEPs 에 없는 콘텐츠 — 구조 부적합 매칭

') # 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'
Holdout 02-2.1 · {hd_title}
' f'V1 1위: Frame {hd_top["frame_number"]} (score {hd_top["matching_score"]:.3f}) → ' f'사용자 검수 결과 비합리. ' f'BEPs 프레임 라이브러리에 원래 이 유형의 콘텐츠가 없어서, 키워드 우연 일치가 ' f'1위로 올라와 버림. V4 에서도 reject 판정. ' f'→ 기존 프레임에 없는 콘텐츠 유형 = 재구성 필요 신호.
') except Exception: pass body.append('
⇒ V1 (키워드) 만으로는 부족. 의미 / 구조 / 적용 가능성 추가 필요 (첨부 3 이후).
') 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('

첨부 3 — 매칭 결과 (V1→V2→V3→V4 캐스케이드)

') body.append('
같은 MDX 섹션에 대해 키워드(V1) → 의미(V2) → 구조(V3) → ' '적용 가능성(V4) 순서대로 후보를 재정렬/판정. TARGET 4 섹션 모두 V4 1위 = 정답.
') 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('
') body.append(f'

{sid} · {title} (정답 Frame {answer})

') body.append(f'
{excerpt}
') body.append('') for v, desc in [('V1', '키워드'), ('V2', '+ 의미'), ('V3', '+ 구조'), ('V4', '+ 적용가능성')]: body.append(f'') body.append('') def cell(frame, score_label, is_ans=False, extra=''): fn = frame inner = (f'' f'
Frame {fn}{"" if not is_ans else " 🎯"}
' f'
{score_label}
' f'
{descriptions.get(fn, "")[:22]}
') if extra: inner += f'
{extra}
' if is_ans: inner = f'
{inner}
' return f'' 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'{label}' body.append(cell(fn4, f"conf {v4_top['confidence']:.3f}", fn4 == answer, extra4)) body.append('
{v}
{desc}
{inner}
') body.append('
핵심: V4 가 최종 의사결정. V4 label ' 'use_as_is / ' 'light_edit 면 실사용 가능, ' 'reject 면 기존 프레임 부적합 → 재구성 신호.
') return html_wrap('첨부 3 — 매칭 결과', ''.join(body)) # ============================================================ # ATTACH 04 — 구조 매칭 키워드 (controlled vocab) # ============================================================ def build_attach04(): body = [] body.append('

첨부 4 — 구조 매칭 키워드 (controlled vocabulary)

') body.append('
V3 구조 매칭이 단순 layout 일치가 아니라 의미 + 시각 의도로 ' '프레임을 구분하도록 하는 어휘 정의. 이 vocab 이 없으면 "3열 병렬" 같은 같은 layout 안에서 ' '다른 콘텐츠의 프레임이 뒤섞여 구분 불가.
') body.append('

content_affinity — 12종 (의미 축, 콘텐츠 성격)

') body.append('') 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'') body.append('
의미예시 (MDX 또는 Frame)
{v}{m}{e}
') body.append('

structure_intent — 9종 (시각 의도, 레이아웃이 전달하는 방식)

') body.append('') 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'') body.append('
의미대표 layout
{v}{m}{e}
') body.append('

alternative_patterns — 대안 레이아웃 관계

') body.append('

각 프레임이 "이 내용을 못 담을 때 어떤 다른 layout 이 대체 가능한가" 를 ' '명시. 기존 방식은 수작업 호환도 테이블(_COMPAT, 커버리지 26%)이었고, ' '새 스키마는 프레임별 alternative_patterns 필드로 파생.

') body.append('') body.append('' '') body.append('' '') body.append('' '') body.append('
예시 프레임기본 layout대안 패턴
Frame 12 · 건설산업 목표cycle-3way3col-parallel (0.7, 수동 시드 — 순환 대신 평면 병렬), ' 'circular-nodes (0.6, 수동 시드 — radial 대안)
Frame 18 · BIM vs DXcompare-rowstable-2col, compare-2col, 2col-paired ' '(파생 — 2-way 비교 계열)
Frame 29 · Process 혁신compare-2banner-top-2col-bottomcompare-2col, table-2col, 2col-paired (파생)
') body.append('

왜 이 vocab 이 필요한가 — 원인/효과

') body.append('') body.append('' '') body.append('' '') body.append('' '') body.append('
이 축이 없으면 발생하는 문제이 축이 해결하는 것
"3열 병렬" 프레임끼리 구분 불가 — Frame 12 (목표 3축) 와 Frame 20 (S/W 필수 3관점) 둘 다 3열인데 의미 다름content_affinitygoal_axes vs capability_requirements 로 구분
"compare-rows" 와 "table-2col" 이 다른 layout 처럼 보이지만 실제론 같은 2-way 비교alternative_patterns 에 명시 — 자동 대체 가능
"혁신", "전환" 같은 단어가 과매칭 — 거의 모든 MDX 에 state_transition 붙음STRONG 패턴(AS-IS/TO-BE/이중 변환) 없이는 인정 안 함 — validator 강화
') return html_wrap('첨부 4 — 구조 매칭 키워드', ''.join(body)) # ============================================================ # ATTACH 05 — fit-v2 샘플 3건 # ============================================================ def build_attach05(v1, v2, v3_r5, v4_r2, descriptions): body = [] body.append('

첨부 5 — fit-v2 적합 샘플

') body.append('
단순 구조 매칭이 아닌 template-fit-v2 판정이 필요한 이유를 3 가지 ' '상황(V1 강함 / V1 약함 / V1 강함 but 재구성 필요) 으로 보여줌.
') 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('
') lines.append(f'

{case_label} — {sid} · {title}

') lines.append(f'
{excerpt}
') lines.append('') lines.append(f'') lines.append(f'') lines.append(f'') lines.append('
V1 점수 (키워드){v1_top["matching_score"]:.3f} → Frame {v1_top["frame_number"]}
V4 1위Frame {v4_top["frame_number"]} conf {v4_top["confidence"]:.3f} ' f'{v4_top["label"]}
V4 axes 분해' f'anchor {v4_top["axes"]["anchor"]} · ' f'cardinality {v4_top["axes"]["cardinality"]} · ' f'relation {v4_top["axes"]["relation"]} · ' f'slot {v4_top["axes"]["slot"]} · ' f'content {v4_top["axes"]["content"]} · ' f'penalty {v4_top["penalty"]}' f'
') # V4 top1 이미지 fn = v4_top['frame_number'] is_ans = (answer is not None and fn == answer) inner = (f'' f'
Frame {fn}{"" if not is_ans else " 🎯 (정답)"}
' f'
{descriptions.get(fn, "")}
') if is_ans: inner = f'
{inner}
' lines.append(f'
{inner}
') lines.append(f'
{analysis["note"]}
') lines.append('
') 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': ('해석: V1 점수 0.937 으로 매우 강함. ' 'V4 axes 도 모두 1.0 에 가깝고 label use_as_is. ' 'fit-v2 의 multi-axis 판정이 "이 프레임은 그대로 써도 된다" 를 명확히 확증. ' 'V1 점수만 있어도 맞지만, V4 의 reject 여부 확인이 있어야 실사용 보장.'), })) # 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': ('해석: V1 점수 0.598 로 경계 이하(< 0.7). 키워드만 보면 ' '애매함. V4 의 content_affinity (capability_requirements 매칭) + ' 'cardinality + relation + slot 종합 판정으로 Frame 13 이 use_as_is 로 확정. ' 'fit-v2 가 V1 의 약한 신호를 보강해 정답에 도달.'), })) # Case 3: V1 강함, V4 reject — 쓸 수 없다는 신호 body.append(render_case('02-2.2', '사례 3 — V1 강함 + V4 reject (재구성 필요 신호)', { 'note_type': 'fail-note', 'note': ('해석: V1 점수 0.840 강하고 1위 Frame 14 는 정답 (사용자 검수). ' '그러나 V4 confidence 는 0.293reject. ' 'axes 분해에서 anchor 와 content 는 맞지만 cardinality/relation/slot 의 점수가 낮음. ' '즉 "정답으로 지목됐지만 그대로 쓰기엔 구조가 부족". ' 'fit-v2 가 점수 1위 ≠ 실사용 가능을 드러내는 핵심 사례. ' '단순 구조 매칭 (V3) 으로는 이 신호를 잡을 수 없음.'), })) body.append('
⇒ fit-v2 는 정답 맞추기가 아니라 실사용 판정. ' '이것이 V3 단순 구조 매칭보다 필요한 이유.
') 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('

첨부 6 — Figma DB 정리 방안 (Frame 12 예시)

') body.append('
템플릿-fit 스키마 기반. 32 프레임 + BEPs 외 디자인들을 동일 스키마로 정리.
') body.append('
') body.append('
') body.append(f'') body.append(f'
Frame 12 · {e["source"]["title"]}
') body.append(f'
{descriptions.get(12, "")}
') body.append('
') body.append('
') body.append('

visual_pattern

') body.append('') body.append(f'') body.append(f'') body.append(f'') card = vp.get("cardinality", {}) body.append(f'') body.append('
layout{vp["layout"]}
family{vp.get("family")}
relation_type{vp.get("relation_type")}
cardinalityideal {card.get("ideal")} / min {card.get("min")} / max {card.get("max")}
') body.append('
') body.append('

content_affinity · structure_intent · alternative_patterns

') body.append('') body.append(f'') body.append(f'') body.append(f'') body.append(f'') body.append('
content_affinity.primary{aff["primary"]}
content_affinity.secondary' + ', '.join(f'{s}' for s in aff.get('secondary', []) or ['(없음)']) + '
structure_intent.primary{intent["primary"]}
structure_intent.secondary' + ', '.join(f'{s}' for s in intent.get('secondary', []) or ['(없음)']) + '
') body.append('

alternative_patterns

') body.append('') for a in alts: body.append(f'' f'') body.append('
patternconfsourcereason
{a["pattern"]}{a["confidence"]}{a.get("source", "derived")}{a["reason"]}
') body.append('

slots · suits · not_suits

') body.append('
') body.append('
') body.append('

slots

') body.append('') for s in slots: body.append(f'' f'') body.append('
idtyperequired
{s["id"]}{s.get("type")}{s.get("required", False)}
') body.append('
') body.append('

suits · not_suits

') body.append('
suits:
    ') for s in suits: body.append(f'
  • {s}
  • ') body.append('
not_suits:
    ') for s in not_suits: body.append(f'
  • {s}
  • ') body.append('
') body.append('
') body.append('

32 프레임 분포 + 정리 상태

') 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('
') body.append('
') body.append('

content_affinity 분포

') body.append('') for k, c in aff_counter.most_common(): body.append(f'') body.append('
primary
{k}{c}
') body.append('
') body.append('

structure_intent 분포

') body.append('') for k, c in intent_counter.most_common(): body.append(f'') body.append('
primary
{k}{c}
') body.append('
') 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'
32 프레임. AI 라벨 초안 → 키워드 튜닝 → ' f'사용자 검토 루프 → 수동 오버라이드 {mo} 건 / ' f'review_queue 잔여 {rv} 건 / resolved {res} 건. ' f'향후 BEPs 외 디자인 추가 시 동일 스키마로 확장. 테스트 섹션 늘려가며 조정 필요.
') 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()