"""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 = [ '
평가 기준: 각 버전 (V1/V2/V3/V4) 의 1위 선택 프레임이 해당 섹션에 합리적으로 적용 가능한가.
', '라벨링은 holdout_labeling_template.yaml 의 rating 필드를 채워 주세요. (값: rational / ambiguous / irrational)
⚠ 평가 시 주의 (반드시 읽어 주세요)
', 'use_as_is/reject 는 template-fit-v1 이 판정한 결과일 뿐. '
'평가는 "V4 라벨이 맞는가" 가 아니라 "이 프레임이 이 섹션에 실제로 합리적인가" 를 사용자 눈으로 판단. '
'V4 가 reject 라고 해도 사용자가 보기에 합리적이면 rational 로 표시.| {v} {subtitle} | ')
body.append('
|---|
| ')
body.append(f' {pick["metric_label"]} {metric_value_html}{extra_html} ')
body.append(f'')
body.append(' | ')
body.append('