"""Pipeline Step 10 — Holdout 평가용 합리성 라벨링 자료 준비. 목적: Holdout 3 섹션 (01-1, 02-1, 02-2.1) 각각에 대해 V1/V2/V3/V4 의 1위 선택을 사용자가 ○/△/× 로 라벨링할 수 있도록 자료를 준비. 평가 방식 (사용자 지침): "정답 프레임" 단일 지목이 아니라 **"이 프레임이 이 섹션에 합리적으로 적용 가능한가"** 를 평가. - rational (○): 적용 가능 - ambiguous (△): 조건부/논란 - irrational (×): 적용 불가 출력: - HOLDOUT_LABELING.html — 각 Holdout 섹션 × 4 버전 1위 프레임을 이미지 + MDX 원문과 함께 표시 - holdout_labeling_template.yaml — 사용자가 rating 필드를 채워 넣을 템플릿 """ import base64 from pathlib import Path import sys import yaml HERE = Path(__file__).parent sys.path.insert(0, str(HERE)) PNG_DIR = (HERE / '..' / '..' / 'data' / 'figma_previews').resolve() HTML_PATH = HERE / 'HOLDOUT_LABELING.html' TEMPLATE_PATH = HERE / 'holdout_labeling_template.yaml' HOLDOUT_SIDS = ['01-1', '02-1', '02-2.1'] _IMG_CACHE = {} def img_data_uri(frame_num): if frame_num in _IMG_CACHE: return _IMG_CACHE[frame_num] p = PNG_DIR / f'{frame_num:02d}.png' if not p.exists(): return '' uri = 'data:image/png;base64,' + base64.b64encode(p.read_bytes()).decode('ascii') _IMG_CACHE[frame_num] = 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_idx = None for i, ln in enumerate(lines): if ln.strip() == cfg['start'].strip(): start_idx = i break end_idx = len(lines) if cfg.get('end_prefix'): for i in range(start_idx + 1, len(lines)): if lines[i].strip().startswith(cfg['end_prefix']): end_idx = i break section = lines[start_idx:end_idx] return section[0].lstrip('#').strip(), '\n'.join(section) 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: raw = sinfo['source_text_raw'] out[fnum] = raw break if fnum not in out: out[fnum] = f'Frame {fnum}' return out def get_v_picks(sid, v1, v2, v3, v4): """각 V 의 1위 반환 — {v, frame_number, metric, extra}""" picks = {} # V1 top = v1['mdx_sections'][sid]['rank_by_matching_score'][0] picks['V1'] = { 'frame_number': top['frame_number'], 'metric_label': 'matching_score', 'metric_value': round(top['matching_score'], 3), 'extra': None, } # V2 v2_top = sorted(v2['mdx_sections'][sid]['v2_rerank'], key=lambda x: x['v2_rank'])[0] picks['V2'] = { 'frame_number': v2_top['frame_number'], 'metric_label': 'semantic_score', 'metric_value': round(v2_top['semantic_score'], 3), 'extra': None, } # V3 v3_top = sorted(v3['mdx_sections'][sid]['v3_rerank'], key=lambda x: x['v3_rank'])[0] picks['V3'] = { 'frame_number': v3_top['frame_number'], 'metric_label': 'structure_compat', 'metric_value': round(v3_top['structure_compat'], 3), 'extra': v3_top.get('fig_layout'), } # V4 v4_top = sorted(v4['mdx_sections'][sid]['v4_judgments'], key=lambda x: x['v4_rank'])[0] picks['V4'] = { 'frame_number': v4_top['frame_number'], 'metric_label': 'confidence', 'metric_value': round(v4_top['confidence'], 3), 'extra': v4_top['label'], } return picks def render_html(sections_data): style = """ body { font-family: -apple-system, "Segoe UI", Pretendard, sans-serif; max-width: 1400px; margin: 2em auto; padding: 0 1.5em 4em; line-height: 1.65; color: #222; background: #f8fafc; } h1 { border-bottom: 3px solid #2563eb; padding-bottom: 0.25em; } h2 { margin-top: 2.5em; background: #e0e7ff; padding: 0.6em 0.9em; border-left: 4px solid #0a6; border-radius: 4px; } h3 { margin-top: 1.5em; color: #1a365d; } .section-block { background: #fff; border-radius: 8px; border: 1px solid #e2e8f0; padding: 1em 1.2em; margin: 1.5em 0; } .mdx-excerpt { background: #fafafa; border-left: 3px solid #64748b; padding: 0.8em 1em; margin: 0.5em 0 1em; font-size: 0.9em; white-space: pre-wrap; font-family: ui-monospace, monospace; max-height: 300px; overflow: auto; } table.v-grid { width: 100%; border-collapse: collapse; table-layout: fixed; } table.v-grid th { background: #1e293b; color: #fff; padding: 10px; text-align: center; font-weight: 700; width: 25%; } table.v-grid td { border: 1px solid #cbd5e1; padding: 12px; text-align: center; vertical-align: top; background: #fff; } table.v-grid img { max-width: 240px; height: auto; border: 1px solid #cbd5e1; border-radius: 4px; display: block; margin: 0 auto 8px; } .frame-label { display: block; font-weight: 600; color: #2563eb; font-size: 1.05em; margin-bottom: 2px; } .frame-desc { display: block; color: #64748b; font-size: 0.85em; margin: 4px 0 8px; min-height: 2em; } .metric { margin-top: 8px; padding-top: 8px; border-top: 1px dashed #e2e8f0; font-size: 0.9em; } .metric .key { color: #64748b; } .metric .value { color: #059669; font-weight: 600; font-family: ui-monospace, monospace; } .rating-box { margin-top: 10px; padding: 8px; background: #fffbeb; border: 2px dashed #fbbf24; border-radius: 4px; font-size: 0.9em; color: #92400e; } .rating-box b { color: #78350f; } .label-use_as_is { background: #d1fae5; color: #065f46; padding: 2px 8px; border-radius: 3px; } .label-light_edit { background: #dbeafe; color: #1e40af; padding: 2px 8px; border-radius: 3px; } .label-restructure { background: #fef3c7; color: #92400e; padding: 2px 8px; border-radius: 3px; } .label-reject { background: #fee2e2; color: #991b1b; padding: 2px 8px; border-radius: 3px; } .guide { background: #eef2ff; border-left: 4px solid #4338ca; padding: 1em 1.2em; border-radius: 4px; margin: 1em 0 2em; } .guide strong { color: #3730a3; } """ body = [ '

Holdout 합리성 라벨링 자료

', '
', '

평가 기준: 각 버전 (V1/V2/V3/V4) 의 1위 선택 프레임이 해당 섹션에 합리적으로 적용 가능한가.

', '', '

라벨링은 holdout_labeling_template.yamlrating 필드를 채워 주세요. (값: rational / ambiguous / irrational)

', '
', '
', '

⚠ 평가 시 주의 (반드시 읽어 주세요)

', '
    ', '
  1. V4 라벨에 끌려가지 마세요. use_as_is/reject 는 template-fit-v1 이 판정한 결과일 뿐. ' '평가는 "V4 라벨이 맞는가" 가 아니라 "이 프레임이 이 섹션에 실제로 합리적인가" 를 사용자 눈으로 판단. ' 'V4 가 reject 라고 해도 사용자가 보기에 합리적이면 rational 로 표시.
  2. ', '
  3. 현 단계는 1위만 평가. "대안 탐색" 관점(2~3위가 더 합리적일 수 있음)은 나중 단계에서 확장. ' '지금은 V2 실사용 가치 검증 목적이므로 1위로 충분.
  4. ', '
', '
', ] for sid, data in sections_data.items(): body.append(f'

{sid} — {data["mdx_title"]}

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

MDX 원문

') body.append(f'
{data["mdx_raw_html"]}
') body.append('

V1 ~ V4 각 1위 선택

') body.append('') body.append('') for v in ['V1', 'V2', 'V3', 'V4']: subtitle = { 'V1': '키워드 baseline', 'V2': '+ 의미 (ko-sroberta)', 'V3': '+ 구조 (Figma×MDX)', 'V4': '+ 판정 (template-fit)', }[v] body.append(f'') body.append('') for v in ['V1', 'V2', 'V3', 'V4']: pick = data['picks'][v] fn = pick['frame_number'] desc = data['descriptions'].get(fn, '') metric_value_html = f'{pick["metric_value"]}' extra_html = '' if pick['extra']: if v == 'V4': extra_html = f'
{pick["extra"]}
' else: extra_html = f'
layout: {pick["extra"]}
' body.append('') body.append('
{v}
{subtitle}
') body.append(f'Frame {fn}') body.append(f'Frame {fn}') body.append(f'{desc}') body.append(f'
{pick["metric_label"]} {metric_value_html}{extra_html}
') body.append(f'
라벨: sid={sid} · version={v} · rating=?
') body.append('
') body.append('
') html = f""" Holdout 라벨링 {''.join(body)} """ HTML_PATH.write_text(html, encoding='utf-8') def build_template(sections_data): template = { 'meta': { 'purpose': 'Holdout 3 섹션 × V1~V4 1위의 합리성 라벨링', 'scale': { 'rational': '○ 이 프레임이 섹션에 합리적으로 적용 가능', 'ambiguous': '△ 조건부 가능 / 논란 있음', 'irrational': '× 섹션과 안 맞음', }, 'instruction': ( '각 sections..picks..rating 을 ' 'rational / ambiguous / irrational 중 하나로 채우세요. ' 'comment 는 선택 — 판단 근거 짧게.' ), 'caution': [ 'V4 라벨(use_as_is/reject)에 끌려가지 말 것 — ' '평가는 "V4 라벨이 맞는가"가 아니라 "이 프레임이 이 섹션에 실제로 합리적인가"를 사용자 눈으로 판단', '현 단계는 1위만 평가 — 대안 탐색(2~3위 합리성) 은 다음 단계로 분리', ], }, 'sections': {}, } for sid, data in sections_data.items(): picks_out = {} for v in ['V1', 'V2', 'V3', 'V4']: p = data['picks'][v] picks_out[v] = { 'frame_number': p['frame_number'], 'metric': {p['metric_label']: p['metric_value']}, **({'extra': p['extra']} if p['extra'] else {}), 'rating': None, # <-- 사용자가 채울 필드 'comment': None, } template['sections'][sid] = { 'mdx_title': data['mdx_title'], 'picks': picks_out, } TEMPLATE_PATH.write_text( yaml.safe_dump(template, allow_unicode=True, sort_keys=False, width=1000), encoding='utf-8', ) 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 = yaml.safe_load((HERE / 'v3_structure_rerank_result.yaml').read_text(encoding='utf-8')) v4 = yaml.safe_load((HERE / 'v4_template_fit_result.yaml').read_text(encoding='utf-8')) auto = yaml.safe_load((HERE / 'auto_anchor_candidates.yaml').read_text(encoding='utf-8')) descriptions = build_frame_descriptions(auto) sections_data = {} for sid in HOLDOUT_SIDS: mdx_title, mdx_raw = extract_mdx_raw(sid) picks = get_v_picks(sid, v1, v2, v3, v4) sections_data[sid] = { 'mdx_title': mdx_title, 'mdx_raw_html': mdx_raw.replace('<', '<').replace('>', '>'), 'picks': picks, 'descriptions': descriptions, } render_html(sections_data) build_template(sections_data) print("=" * 70) print("Holdout 라벨링 자료 생성 완료") print("=" * 70) print(f" html: {HTML_PATH}") print(f" template: {TEMPLATE_PATH}") print() print("사용자 작업:") print(" 1. HOLDOUT_LABELING.html 을 열어 3 섹션 × 4 버전 1위 프레임 확인") print(" 2. holdout_labeling_template.yaml 의 rating 필드 채우기") print(" (rational / ambiguous / irrational)") print(" 3. 완료 후 알려주시면 평가 리포트 생성") if __name__ == '__main__': main()