- src: phase_z2 composition/mapper/pipeline/placement_planner/retry, ai_fallback(prompts/schema/validate), mdx_text_atoms 신규 - Front: PipelineTracePanel 신규, FramePanel/SlideCanvas/Home/designAgentApi 등 갱신 + 테스트 4종 추가 - templates/phase_z2: catalog(component_expansion_registry, node_slot_mapping 신규), frames, families, slide_base 갱신 - tests/matching: phase2~26 매칭 실험 스크립트·리포트·온톨로지 전체 (미커밋 진행분) - tests: b4_v4 evidence, task5~28.5 시리즈, regression(imp95 baseline) 등 신규 테스트 대량 추가 - docs/reference: MDX 구조 인벤토리, MDX→Frame 구조 계약 문서 - scripts: mdx 계약/parity/coverage/viewport 체크, gitea comment, run sync 유틸 - .gitignore: tmp*.json, chromedriver, .orchestrator, *.pkl, Front_test* 등 임시/스냅샷 제외 미완성 작업의 보존용 스냅샷 커밋 (2026-07-02) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
303 lines
13 KiB
Python
303 lines
13 KiB
Python
"""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 = [
|
||
'<h1>Holdout 합리성 라벨링 자료</h1>',
|
||
'<div class="guide">',
|
||
'<p><strong>평가 기준</strong>: 각 버전 (V1/V2/V3/V4) 의 1위 선택 프레임이 해당 섹션에 <b>합리적으로 적용 가능한가</b>.</p>',
|
||
'<ul>',
|
||
'<li><b>합리적 (rational ○)</b>: 이 프레임이 그 섹션에 맞게 적용 가능</li>',
|
||
'<li><b>애매 (ambiguous △)</b>: 조건부 가능, 논란 있음</li>',
|
||
'<li><b>비합리적 (irrational ×)</b>: 섹션과 안 맞음</li>',
|
||
'</ul>',
|
||
'<p>라벨링은 <code>holdout_labeling_template.yaml</code> 의 <code>rating</code> 필드를 채워 주세요. (값: <code>rational</code> / <code>ambiguous</code> / <code>irrational</code>)</p>',
|
||
'</div>',
|
||
'<div class="guide" style="background:#fef2f2;border-left-color:#dc2626">',
|
||
'<p><strong>⚠ 평가 시 주의 (반드시 읽어 주세요)</strong></p>',
|
||
'<ol style="margin:0.5em 0 0 1.2em;padding:0">',
|
||
'<li><b>V4 라벨에 끌려가지 마세요.</b> <code>use_as_is</code>/<code>reject</code> 는 template-fit-v1 이 판정한 결과일 뿐. '
|
||
'평가는 "V4 라벨이 맞는가" 가 아니라 "<b>이 프레임이 이 섹션에 실제로 합리적인가</b>" 를 사용자 눈으로 판단. '
|
||
'V4 가 <code>reject</code> 라고 해도 사용자가 보기에 합리적이면 <code>rational</code> 로 표시.</li>',
|
||
'<li><b>현 단계는 1위만 평가.</b> "대안 탐색" 관점(2~3위가 더 합리적일 수 있음)은 나중 단계에서 확장. '
|
||
'지금은 V2 실사용 가치 검증 목적이므로 1위로 충분.</li>',
|
||
'</ol>',
|
||
'</div>',
|
||
]
|
||
|
||
for sid, data in sections_data.items():
|
||
body.append(f'<h2>{sid} — {data["mdx_title"]}</h2>')
|
||
body.append('<div class="section-block">')
|
||
body.append('<h3>MDX 원문</h3>')
|
||
body.append(f'<div class="mdx-excerpt">{data["mdx_raw_html"]}</div>')
|
||
|
||
body.append('<h3>V1 ~ V4 각 1위 선택</h3>')
|
||
body.append('<table class="v-grid">')
|
||
body.append('<thead><tr>')
|
||
for v in ['V1', 'V2', 'V3', 'V4']:
|
||
subtitle = {
|
||
'V1': '키워드 baseline',
|
||
'V2': '+ 의미 (ko-sroberta)',
|
||
'V3': '+ 구조 (Figma×MDX)',
|
||
'V4': '+ 판정 (template-fit)',
|
||
}[v]
|
||
body.append(f'<th>{v}<br><small style="font-weight:normal;color:#cbd5e1">{subtitle}</small></th>')
|
||
body.append('</tr></thead><tbody><tr>')
|
||
|
||
for v in ['V1', 'V2', 'V3', 'V4']:
|
||
pick = data['picks'][v]
|
||
fn = pick['frame_number']
|
||
desc = data['descriptions'].get(fn, '')
|
||
metric_value_html = f'<span class="value">{pick["metric_value"]}</span>'
|
||
extra_html = ''
|
||
if pick['extra']:
|
||
if v == 'V4':
|
||
extra_html = f'<div style="margin-top:6px"><span class="label-{pick["extra"]}">{pick["extra"]}</span></div>'
|
||
else:
|
||
extra_html = f'<div style="margin-top:4px;font-size:0.8em;color:#64748b">layout: {pick["extra"]}</div>'
|
||
|
||
body.append('<td>')
|
||
body.append(f'<img src="{img_data_uri(fn)}" alt="Frame {fn}"/>')
|
||
body.append(f'<span class="frame-label">Frame {fn}</span>')
|
||
body.append(f'<span class="frame-desc">{desc}</span>')
|
||
body.append(f'<div class="metric"><span class="key">{pick["metric_label"]}</span> {metric_value_html}{extra_html}</div>')
|
||
body.append(f'<div class="rating-box"><b>라벨</b>: <code>sid={sid} · version={v} · rating=?</code></div>')
|
||
body.append('</td>')
|
||
|
||
body.append('</tr></tbody></table>')
|
||
body.append('</div>')
|
||
|
||
html = f"""<!DOCTYPE html>
|
||
<html lang="ko">
|
||
<head><meta charset="utf-8"><title>Holdout 라벨링</title><style>{style}</style></head>
|
||
<body>
|
||
{''.join(body)}
|
||
</body></html>"""
|
||
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.<sid>.picks.<V>.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()
|