"""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"""
MDX 문서의 각 섹션에 가장 맞는 Figma 프레임을 자동 매칭하는 파이프라인. 텍스트 신호부터 시작해 의미·구조·적용 가능성을 단계적으로 쌓아가며 검증.
| 단계 | 추가 축 | 확인 포인트 | 결과 |
|---|---|---|---|
| V1 키워드 | 3-layer 키워드 점수 (단독/묶음/연관) | 기본 후보 필터 — 얼마나 맞는가, 어디서 한계가 드러나는가 | TARGET 4/4 1위 정답. 단, 점수 0.7 미만 케이스(03-1 = 0.598)는 애매 |
| V2 + 의미 | MDX summary ↔ Frame content 의 ko-sroberta cosine | 같은 의미인데 키워드가 다른 경우 잡히나 | Holdout 02-2.1: V1 비합리 → V2 합리 (교정 사례 확인) |
| V3 + 구조 | layout / cardinality / relation / alternative patterns | 구조적으로 담을 수 있는 프레임인가 | 기존 _COMPAT 대체. 3-way 순환(Frame 12) 같은 고아 패턴도 대안 연결 |
| V4 + 적용 가능성 | template-fit 멀티게이트 + penalty (adaptation / not_suits) | 정답 맞추기가 아니라 실제 쓸 수 있는가 | Holdout 합리성 평가 V4 78% (V1/V2 33%, V3 10%) — 가장 안정 |
V1 점수로 가지를 나누면 실제로 깔끔한 라우팅이 나옴.
| V1 점수 | 의사결정 | 사례 |
|---|---|---|
≥ 0.70 강함 | V1 top1 그대로 사용 · V4 는 실사용 가능성 검증 역할 (use_as_is/reject) | 01-2 (0.937), 02-2.2 (0.840), 03-2 (0.705) |
0.40 ~ 0.70 약함 | V4 가 재정렬/판정 — label 이 use_as_is / light_edit 이면 사용 | 03-1 (0.598) → V4 로 Frame 13 확정 ✓ |
< 0.40 매우 약함 | V4 가 reject 가능성 높음 — 기존 프레임 없음, 재구성 단계로 | Holdout 02-2.1 (0.246) → V4 reject ← 프레임 라이브러리에 없는 유형 |
V4 가 쓸 수 있으려면 프레임마다 아래 필드 + 근거(evidence)가 필요.
| 필드 | 무엇 | 왜 |
|---|---|---|
visual_pattern | layout · family · axis · relation_type · cardinality | 구조 매칭 기본 축 |
content_affinity | 12종 enum (concept_definition / goal_axes / persona_benefit / before_after_change / capability_requirements / …) | 같은 레이아웃이라도 의미가 다른 프레임을 구분 |
structure_intent | 9종 enum (binary_compare / multi_parallel / cycle_interrelation / state_transition / …) | 시각적으로 전달하려는 의도 (내용과 직교) |
alternative_patterns | 이 레이아웃이 담지 못할 때의 대체 layout 목록 + confidence | "대안 찾기" — 고아 layout (cycle-3way 등) 도 연결 |
slots · suits · not_suits | 편집 영역 + 적합/부적합 기준 | template-fit 판정 근거 |
evidence · resolved_from_review_queue | 라벨 근거 + 수동 검수 이력 | SSOT 추적성 — 왜 이 라벨이 붙었는지 언제나 확인 가능 |
현재 상태: 32 프레임 일괄 AI 라벨링 → 키워드 튜닝 3회 반복 → 수동 오버라이드 3건 (SSOT 확정) + review_queue 3건 잔여. 1 건 해제 (Frame 13).
''') # Footer: 샘플 목록 body.append('| # | 파일 | 내용 |
|---|---|---|
| 1 | SAMPLE_01_V1_KEYWORD.html | TARGET 4 섹션 × MDX 원문 요약 + 추출 키워드 + V1 Top-3 |
| 2 | SAMPLE_02_V1_V4_CASCADE.html | TARGET 4 섹션 × V1/V2/V3/V4 단계별 Top-1 비교 + 라벨 |
| 3 | SAMPLE_03_SCHEMA_STACK.html | 왜 V1→V2→V3→V4 로 쌓았는가 (층별 해결 문제 + 대표 사례) |
| 4 | SAMPLE_04_FIGMA_DB.html | Figma DB 최종 형태 (Frame 12 전체 필드 예시 + 32 프레임 요약) |
| {v} {desc} | ')
body.append('
|---|
| {inner} | ' 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('
| V1 키워드 |
→ | V1 + V2 + 의미 |
→ | V1 + V2 + V3 + 구조 |
→ | V1 + V2 + V3 + V4 + 적용 가능성 |
| 층 | ' '축 / 신호 | ' '해결하는 문제 | ' '대표 사례 (TARGET) | ' '
|---|---|---|---|
| V1 키워드 baseline |
3-layer: 단독 대표 키워드 / 키워드 묶음 / 연관 키워드0.30·단독 + 0.50·묶음 + 0.20·연관 |
가장 빠른 후보 필터. 같은 용어를 다루는 프레임을 찾음. 점수가 강하면 그대로 사용 가능. | 01-2 "용어간 상호관계" → Frame 18 (0.937) ✓ · 약한 사례 03-1 (0.598) → V4 까지 가야 교정 |
| V2 + 의미 |
MDX summary ↔ Frame content 의 ko-sroberta cosineV1 Top-K 내 재정렬 |
키워드가 달라도 내용/주제가 비슷한 프레임 식별. V1 이 놓치는 의미 유사성 보강. | Holdout 02-2.1: V1 비합리 → V2 합리 (의미 축이 교정) |
| V3 + 구조 |
MDX 구조(코드 추출) × Figma 구조(AI 산출) content_affinity + structure_intent + alternative_patterns |
같은 주제여도 구조적으로 담을 수 있는 프레임인지 판단. 비교형/병렬형/순환형 등 구분. | 기존 _COMPAT 테이블 대체. 3-way 순환 (Frame 12) 은 3col-parallel 대안으로 명시. |
| V4 + 적용 가능성 |
template-fit-v1 multi-gate anchor + cardinality + relation + slot + content − penalties |
이 프레임을 실제로 쓸 수 있는지 최종 판정. use_as_is / light_edit / restructure / reject 4단계. | 02-2.2: V1 점수 0.840 (강함) + 정답 frame 1위 → 그러나 V4 reject ← "쓸 수 있는 프레임 아님" 신호 |
| V1 점수 | 의사결정 |
|---|---|
≥ 0.70 강함 | V1 top1 사용 · V4 는 "실사용 가능성" 검증 (라벨만 참고) |
0.40 ~ 0.70 약함 | V4 재정렬에 맡김 · label 이 use_as_is/light_edit 이면 사용 |
< 0.40 매우 약함 | V4 가 reject 할 가능성 높음 → 기존 프레임 없음, 재구성 단계 |
layout | {vp["layout"]} (원본 {e["source"].get("original_layout")}) |
family | {vp.get("family")} |
relation_type | {vp.get("relation_type")} |
cardinality | ideal {card.get("ideal")} / min {card.get("min")} / max {card.get("max")} |
| primary | {aff["primary"]} |
| secondary | ' +
', '.join(f'{s}' for s in aff.get('secondary', []) or ['(없음)']) + ' |
| evidence.primary | source: {evp.get("source")}'
+ (f" · keywords: {evp.get('keywords')}" if evp.get('keywords') else '')
+ (f" · rule: {evp.get('rule')}" if evp.get('rule') else '')
+ f" · confidence: {evp.get('confidence')} |
| primary | {intent["primary"]} |
| secondary | ' +
', '.join(f'{s}' for s in intent.get('secondary', []) or ['(없음)']) + ' |
| pattern | confidence | source | reason |
|---|---|---|---|
{a["pattern"]} | {a["confidence"]} | ' f'{a.get("source", "derived")} | {a["reason"]} |
| id | type | required | 제약 |
|---|---|---|---|
{s["id"]} | {s.get("type")} | ' f'{s.get("required", False)} | {", ".join(constraints) or "—"} |
| primary | 프레임 수 |
|---|---|
{k} | {c} |
| primary | 프레임 수 |
|---|---|
{k} | {c} |