- 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>
901 lines
49 KiB
Python
901 lines
49 KiB
Python
"""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 블록 진입/종료 (<div>, <span>, <details>, <summary> 등)
|
||
if in_jsx_block:
|
||
if s.endswith('}}>') or re.search(r'</(div|span|details|summary)\s*>', s):
|
||
in_jsx_block = False
|
||
continue
|
||
if re.match(r'^<(div|span|details|summary)\b', s):
|
||
# 같은 줄에서 닫히면 skip only, 그렇지 않으면 block 진입
|
||
if not re.search(r'(/>|</(div|span|details|summary)>)\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'(?<!\*)\*([^*]+)\*(?!\*)', r'\1', s) # italic
|
||
s = re.sub(r'`([^`]+)`', r'\1', s) # inline code
|
||
|
||
# 너무 짧거나 한글/영문자 없는 줄 스킵
|
||
if len(s) < 6 or not re.search(r'[가-힣A-Za-z]', s):
|
||
continue
|
||
|
||
buf.append(s)
|
||
if len(buf) >= 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"""<!DOCTYPE html>
|
||
<html lang="ko"><head><meta charset="utf-8"><title>{title}</title>
|
||
<style>{A4_CSS}</style></head><body>{body}</body></html>"""
|
||
|
||
|
||
# ============================================================
|
||
# MEETING BRIEF (업데이트 — 5-step narrative)
|
||
# ============================================================
|
||
|
||
def build_meeting_brief():
|
||
body = []
|
||
body.append('<h1>MDX ↔ Figma 프레임 자동 매칭 — 현황 및 제안</h1>')
|
||
body.append('<div class="meta-bar">첨부 6장과 함께 검토. 이 한 장이 전체 요약.</div>')
|
||
|
||
body.append('<h2>1. 개요</h2>')
|
||
body.append('<ul style="margin:3pt 0;padding-left:14pt;font-size:9.5pt;line-height:1.5">')
|
||
body.append('<li>CEL 각 섹션별 내용(MDX)에 <strong>가장 적합한 Figma 프레임을 매칭</strong>하는 프로세스 확인</li>')
|
||
body.append('<li>실행 내용: Figma 디자인에서 <strong>텍스트 요소</strong>를 추출하여 MDX와의 유사도를 통한 매칭 실시</li>')
|
||
body.append('</ul>')
|
||
|
||
# 5-step narrative
|
||
body.append('<h2>2. 시행 단계</h2>')
|
||
body.append('<ol class="steps">')
|
||
|
||
body.append('''
|
||
<li><span class="step-title">기존 라이브러리/모델 적용</span> — tf-idf, sobert, BM25, cross-encoder 등 적용
|
||
<div class="step-fail">(*) 일반적인 단어, 동음이의/도메인 용어 부재 등의 요인으로 매칭 점수 낮거나 미매칭.
|
||
예: "전환"(일반) vs "디지털 전환"(DX) 혼동, BEPs 고유어(결과혁신/과정혁신 등) 학습 안 됨.</div>
|
||
</li>
|
||
''')
|
||
|
||
body.append('''
|
||
<li><span class="step-title">AI로 디자인 분석하여 매칭</span>
|
||
<div class="step-fail">(*) 없는 단어, 추론 단어 등의 요인으로 매칭 점수 낮게 나오며, 구조가 맞지 않음.</div>
|
||
</li>
|
||
''')
|
||
|
||
body.append('''
|
||
<li><span class="step-title">키워드 전처리 및 가중치를 통한 매칭</span> <span style="color:#2563eb;font-size:8.5pt">(첨부 1. 키워드 정리)</span>
|
||
<div style="font-size:8.5pt;margin:2pt 0;color:#475569">
|
||
(*) Figma에서 추출한 텍스트에서 해당 디자인의 <strong>핵심 키워드 / 세트 키워드 / 연관 키워드</strong>를 구분,
|
||
MDX에서 해당 키워드를 통해 매칭 점수 정리 <span style="color:#2563eb">(첨부 2. 키워드 매칭)</span>
|
||
</div>
|
||
<div class="step-ok">→ 32개 디자인 중 적합한 프레임 매칭. 하지만 2~3순위 프레임을 활용하기엔 부적절.</div>
|
||
<div class="step-fail">⇒ BEPs에 없는 내용에 대한 디자인 매칭 시 구조에 적합하지 않은 디자인 매칭. <span style="color:#2563eb">(첨부 2. 키워드 매칭 — 한계)</span></div>
|
||
</li>
|
||
''')
|
||
|
||
body.append('''
|
||
<li><span class="step-title">키워드 매칭 + 의미 / 구조 / 적용 가능성을 통한 매칭</span> <span style="color:#2563eb;font-size:8.5pt">(첨부 3. 매칭 결과)</span>
|
||
<div style="font-size:8.5pt;margin:2pt 0;color:#475569">
|
||
(*) 매칭 결과 표
|
||
</div>
|
||
<div class="step-ok">→ 구조 매칭 시, 내용에 기반하여 적합한 디자인과 매칭 <span style="color:#2563eb">(첨부 4. 구조 매칭 키워드)</span></div>
|
||
<div class="step-ok">(*) 단순 구조 매칭이 아닌 <strong>fit-v2</strong>가 가장 적합 <span style="color:#2563eb">(첨부 5. 샘플)</span></div>
|
||
</li>
|
||
''')
|
||
|
||
body.append('''
|
||
<li><span class="step-title">Figma 프레임별 추출 내용 및 정리 방안</span>
|
||
<div style="font-size:8.5pt;margin:2pt 0;color:#475569">
|
||
→ 32개 프레임 외, BEPs에서의 디자인들을 프레임별로 추출 및 정리 <span style="color:#2563eb">(첨부 6. 정리 방안)</span>
|
||
</div>
|
||
<div class="step-ok">→ 테스트를 늘려가며 조금 더 조정할 필요</div>
|
||
</li>
|
||
''')
|
||
|
||
body.append('</ol>')
|
||
|
||
# 첨부 목차
|
||
body.append('<h2>첨부 6장</h2>')
|
||
body.append('''
|
||
<table style="margin-top:3pt"><thead><tr>
|
||
<th style="width:6%">#</th><th style="width:36%">파일</th><th>내용</th>
|
||
</tr></thead><tbody>
|
||
<tr><td><strong>1</strong></td><td><code>ATTACH_01_KEYWORD_ORGANIZATION.html</code></td><td>Figma 프레임별 3-layer 키워드 정리 (핵심/세트/연관)</td></tr>
|
||
<tr><td><strong>2</strong></td><td><code>ATTACH_02_KEYWORD_MATCHING.html</code></td><td>V1 키워드 매칭 결과 + 한계 사례 (BEPs 밖 콘텐츠)</td></tr>
|
||
<tr><td><strong>3</strong></td><td><code>ATTACH_03_CASCADE_RESULT.html</code></td><td>V1 키워드 → + 의미 → + 구조 → + 적용 가능성 단계별 결과</td></tr>
|
||
<tr><td><strong>4</strong></td><td><code>ATTACH_04_STRUCTURE_VOCAB.html</code></td><td>구조 매칭 키워드 정의 (content_affinity 12, structure_intent 9, alt_patterns)</td></tr>
|
||
<tr><td><strong>5</strong></td><td><code>ATTACH_05_SAMPLES.html</code></td><td>fit-v2 적합 샘플 3건 (V1 강함/약함/reject 각 사례)</td></tr>
|
||
<tr><td><strong>6</strong></td><td><code>ATTACH_06_DB_ORGANIZATION.html</code></td><td>Figma DB 형태 (Frame 예시 + 32 프레임 요약)</td></tr>
|
||
</tbody></table>
|
||
''')
|
||
|
||
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'<div class="{box_class} avoid-break">')
|
||
badge = ' 🎯 정답' if is_answer else ''
|
||
html.append(f'<h3>Frame {fn}{badge} '
|
||
f'<span style="color:#64748b;font-weight:400;font-size:9pt">'
|
||
f'({rank_label} · 매칭점수 <code>{score:.3f}</code>)</span></h3>')
|
||
|
||
html.append('<div class="fm-body">')
|
||
html.append('<div class="fm-thumb">')
|
||
html.append(f'<img src="{img_uri(fn)}" alt="{fn:02d}"/>')
|
||
if frame_desc:
|
||
html.append(f'<div class="frame-desc">{frame_desc[:40]}</div>')
|
||
html.append('</div>')
|
||
|
||
html.append('<div class="fm-detail">')
|
||
|
||
# 핵심 (단독 대표 키워드): 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('<div class="kw-line"><strong>핵심 키워드</strong><br/>')
|
||
html.append(f' <span class="hit-chip">(o) {trim_list(s_hit_list, MAX_INLINE_KW)}</span>')
|
||
html.append(f' <span class="miss-chip">(x) {trim_list(s_miss_list, MAX_INLINE_KW)}</span>')
|
||
html.append('</div>')
|
||
|
||
# 세트 (키워드 묶음): 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('<div class="kw-line"><strong>키워드 세트</strong><br/>')
|
||
html.append(f' <span class="hit-chip">(o) {trim_sets(hit_sets, MAX_INLINE_SETS)}</span>')
|
||
html.append(f' <span class="miss-chip">(x) {trim_sets(miss_sets, MAX_INLINE_SETS)}</span>')
|
||
html.append('</div>')
|
||
|
||
# 연관 키워드: 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('<div class="kw-line"><strong>연관 키워드</strong><br/>')
|
||
html.append(f' <span class="hit-chip">(o) {trim_list(r_hit_list, MAX_INLINE_KW)}</span>')
|
||
html.append(f' <span class="miss-chip">(x) 미매칭 {r_miss_ct}개</span>')
|
||
html.append('</div>')
|
||
|
||
html.append('</div>') # close fm-detail
|
||
html.append('</div>') # close fm-body
|
||
html.append('</div>') # close frame-match
|
||
return ''.join(html)
|
||
|
||
|
||
def build_attach01(v1, descriptions):
|
||
"""6-page V1 매칭 상세 breakdown. 각 page 1 MDX 섹션 + Top-3 프레임."""
|
||
body = []
|
||
body.append('<h1>첨부 1 — V1 키워드 매칭 상세</h1>')
|
||
body.append('<div class="meta-bar">MDX 섹션마다 Top-3 프레임의 <strong>매칭 근거</strong>. '
|
||
'각 프레임의 핵심/세트/연관 키워드 중 어떤 것이 MDX 에 등장했는지 (o/x) 확인. '
|
||
'TARGET 4 (매칭된 샘플) + Holdout 2 (매칭 안된 샘플) = 총 6 페이지.</div>')
|
||
|
||
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('<div class="page-break"></div>')
|
||
|
||
page_num = idx + 1
|
||
body.append(f'<div class="page-header-small">샘플 {page_num} / {total_pages}</div>')
|
||
|
||
title, raw = extract_mdx_raw(sid)
|
||
# MDX 요약 (2-3줄, 약 200자)
|
||
excerpt = mdx_excerpt(raw, 220)
|
||
|
||
tag = ('<span class="section-tag tag-matched">매칭됨</span>' if is_matched
|
||
else '<span class="section-tag tag-unmatched">매칭 안됨</span>')
|
||
body.append(f'<h1>MDX {sid} · {title} {tag}</h1>')
|
||
body.append(f'<div class="mdx-card"><div class="mdx-excerpt">{excerpt}</div></div>')
|
||
|
||
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('<h2>프레임별 매칭 breakdown (Top-3)</h2>')
|
||
|
||
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('<h1>첨부 2 — 키워드 매칭 결과 + 한계</h1>')
|
||
body.append('<div class="meta-bar">MDX 섹션별 추출 키워드 × 32 프레임 키워드 매칭. 결과 + 2~3순위의 한계 / BEPs 밖 콘텐츠 한계.</div>')
|
||
|
||
body.append('<h2>매칭 공식</h2>')
|
||
body.append('<table><thead><tr><th style="width:30%">신호</th><th style="width:50%">의미</th><th style="width:20%">가중치</th></tr></thead><tbody>')
|
||
body.append('<tr><td>단독 대표 키워드</td><td>특정 프레임을 혼자서도 강하게 가리키는 키워드 매칭</td><td><code>0.30</code></td></tr>')
|
||
body.append('<tr><td>세트 키워드 묶음</td><td>프레임 원문 한 줄에서 함께 등장하는 키워드 조합 매칭</td><td><code>0.50</code></td></tr>')
|
||
body.append('<tr><td>연관 키워드</td><td>참고 키워드 겹침 정도</td><td><code>0.20</code></td></tr>')
|
||
body.append('</tbody></table>')
|
||
|
||
body.append('<h2>TARGET 4 섹션 결과</h2>')
|
||
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('<div class="avoid-break mdx-card">')
|
||
body.append(f'<div class="mdx-title">{sid} · {title}</div>')
|
||
body.append(f'<div style="font-size:8.5pt;color:#475569">1위 점수 <code>{top_score:.3f}</code> '
|
||
f'<span style="color:{strength_color};font-weight:700">[{strength}]</span> · 1-2위 격차 <code>{gap:.3f}</code></div>')
|
||
body.append('<div class="flex-grid" style="margin-top:3pt"><div class="flex-row">')
|
||
for r in top3:
|
||
fn = r['frame_number']
|
||
is_ans = fn == answer
|
||
inner = (f'<img src="{img_uri(fn)}" class="frame-thumb" style="max-width:38mm"/>'
|
||
f'<div class="frame-label">Frame {fn}{"" if not is_ans else " 🎯"}</div>'
|
||
f'<div class="frame-score">{r["matching_score"]:.3f}</div>'
|
||
f'<div class="frame-desc">{descriptions.get(fn, "")[:24]}</div>')
|
||
if is_ans:
|
||
inner = f'<div class="answer-box">{inner}</div>'
|
||
body.append(f'<div class="frame-cell flex-cell">{inner}</div>')
|
||
body.append('</div></div></div>')
|
||
|
||
body.append('<h2 class="page-break">한계 사례</h2>')
|
||
|
||
body.append('<h3>한계 1. 2~3순위 프레임이 부적절</h3>')
|
||
body.append('<div class="fail-note">1위는 대체로 맞지만, V1 점수만으로는 2~3위가 같은 수준의 대안인지 '
|
||
'단순 키워드 중복으로 올라왔는지 구분 어려움. 예: 01-2 의 2위 Frame 27 (score 0.392) 은 '
|
||
'키워드는 겹치지만 구조(central-split)는 부적합.</div>')
|
||
|
||
body.append('<h3>한계 2. BEPs 에 없는 콘텐츠 — 구조 부적합 매칭</h3>')
|
||
# 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'<div class="fail-note">Holdout <strong>02-2.1</strong> · {hd_title}<br>'
|
||
f'V1 1위: Frame {hd_top["frame_number"]} (score <code>{hd_top["matching_score"]:.3f}</code>) → '
|
||
f'사용자 검수 결과 <strong>비합리</strong>. '
|
||
f'BEPs 프레임 라이브러리에 원래 이 유형의 콘텐츠가 없어서, 키워드 우연 일치가 '
|
||
f'1위로 올라와 버림. V4 에서도 reject 판정. '
|
||
f'→ <strong>기존 프레임에 없는 콘텐츠 유형 = 재구성 필요 신호</strong>.</div>')
|
||
except Exception:
|
||
pass
|
||
|
||
body.append('<div class="note">⇒ V1 (키워드) 만으로는 부족. 의미 / 구조 / 적용 가능성 추가 필요 (첨부 3 이후).</div>')
|
||
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('<h1>첨부 3 — 매칭 결과 (V1→V2→V3→V4 캐스케이드)</h1>')
|
||
body.append('<div class="meta-bar">같은 MDX 섹션에 대해 키워드(V1) → 의미(V2) → 구조(V3) → '
|
||
'적용 가능성(V4) 순서대로 후보를 재정렬/판정. TARGET 4 섹션 모두 V4 1위 = 정답.</div>')
|
||
|
||
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('<div class="avoid-break">')
|
||
body.append(f'<h2>{sid} · {title} (정답 Frame {answer})</h2>')
|
||
body.append(f'<div class="mdx-card"><div class="mdx-excerpt">{excerpt}</div></div>')
|
||
|
||
body.append('<table><thead><tr>')
|
||
for v, desc in [('V1', '키워드'), ('V2', '+ 의미'), ('V3', '+ 구조'), ('V4', '+ 적용가능성')]:
|
||
body.append(f'<th>{v}<br><span style="font-weight:400;font-size:8pt;color:#cbd5e1">{desc}</span></th>')
|
||
body.append('</tr></thead><tbody><tr>')
|
||
|
||
def cell(frame, score_label, is_ans=False, extra=''):
|
||
fn = frame
|
||
inner = (f'<img src="{img_uri(fn)}" class="frame-thumb" style="max-width:38mm"/>'
|
||
f'<div class="frame-label">Frame {fn}{"" if not is_ans else " 🎯"}</div>'
|
||
f'<div class="frame-score">{score_label}</div>'
|
||
f'<div class="frame-desc">{descriptions.get(fn, "")[:22]}</div>')
|
||
if extra:
|
||
inner += f'<div style="margin-top:2pt">{extra}</div>'
|
||
if is_ans:
|
||
inner = f'<div class="answer-box">{inner}</div>'
|
||
return f'<td class="frame-cell">{inner}</td>'
|
||
|
||
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'<span class="label-badge label-{label}">{label}</span>'
|
||
body.append(cell(fn4, f"conf {v4_top['confidence']:.3f}", fn4 == answer, extra4))
|
||
body.append('</tr></tbody></table></div>')
|
||
|
||
body.append('<div class="note">핵심: V4 가 최종 의사결정. V4 label '
|
||
'<span class="label-badge label-use_as_is">use_as_is</span> / '
|
||
'<span class="label-badge label-light_edit">light_edit</span> 면 실사용 가능, '
|
||
'<span class="label-badge label-reject">reject</span> 면 기존 프레임 부적합 → 재구성 신호.</div>')
|
||
return html_wrap('첨부 3 — 매칭 결과', ''.join(body))
|
||
|
||
|
||
# ============================================================
|
||
# ATTACH 04 — 구조 매칭 키워드 (controlled vocab)
|
||
# ============================================================
|
||
|
||
def build_attach04():
|
||
body = []
|
||
body.append('<h1>첨부 4 — 구조 매칭 키워드 (controlled vocabulary)</h1>')
|
||
body.append('<div class="meta-bar">V3 구조 매칭이 단순 layout 일치가 아니라 <strong>의미 + 시각 의도</strong>로 '
|
||
'프레임을 구분하도록 하는 어휘 정의. 이 vocab 이 없으면 "3열 병렬" 같은 같은 layout 안에서 '
|
||
'다른 콘텐츠의 프레임이 뒤섞여 구분 불가.</div>')
|
||
|
||
body.append('<h2>content_affinity — 12종 (의미 축, 콘텐츠 성격)</h2>')
|
||
body.append('<table><thead><tr><th style="width:22%">값</th><th style="width:32%">의미</th><th>예시 (MDX 또는 Frame)</th></tr></thead><tbody>')
|
||
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'<tr><td><code>{v}</code></td><td>{m}</td><td>{e}</td></tr>')
|
||
body.append('</tbody></table>')
|
||
|
||
body.append('<h2>structure_intent — 9종 (시각 의도, 레이아웃이 전달하는 방식)</h2>')
|
||
body.append('<table><thead><tr><th style="width:22%">값</th><th style="width:32%">의미</th><th>대표 layout</th></tr></thead><tbody>')
|
||
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'<tr><td><code>{v}</code></td><td>{m}</td><td><code>{e}</code></td></tr>')
|
||
body.append('</tbody></table>')
|
||
|
||
body.append('<h2>alternative_patterns — 대안 레이아웃 관계</h2>')
|
||
body.append('<p style="font-size:9pt">각 프레임이 "이 내용을 못 담을 때 어떤 다른 layout 이 대체 가능한가" 를 '
|
||
'명시. 기존 방식은 수작업 호환도 테이블(<code>_COMPAT</code>, 커버리지 26%)이었고, '
|
||
'새 스키마는 프레임별 <code>alternative_patterns</code> 필드로 파생.</p>')
|
||
body.append('<table><thead><tr><th style="width:25%">예시 프레임</th><th style="width:25%">기본 layout</th><th>대안 패턴</th></tr></thead><tbody>')
|
||
body.append('<tr><td>Frame 12 · 건설산업 목표</td><td><code>cycle-3way</code></td>'
|
||
'<td><code>3col-parallel</code> (0.7, 수동 시드 — 순환 대신 평면 병렬), '
|
||
'<code>circular-nodes</code> (0.6, 수동 시드 — radial 대안)</td></tr>')
|
||
body.append('<tr><td>Frame 18 · BIM vs DX</td><td><code>compare-rows</code></td>'
|
||
'<td><code>table-2col</code>, <code>compare-2col</code>, <code>2col-paired</code> '
|
||
'(파생 — 2-way 비교 계열)</td></tr>')
|
||
body.append('<tr><td>Frame 29 · Process 혁신</td><td><code>compare-2banner-top-2col-bottom</code></td>'
|
||
'<td><code>compare-2col</code>, <code>table-2col</code>, <code>2col-paired</code> (파생)</td></tr>')
|
||
body.append('</tbody></table>')
|
||
|
||
body.append('<h2>왜 이 vocab 이 필요한가 — 원인/효과</h2>')
|
||
body.append('<table><thead><tr><th style="width:40%">이 축이 없으면 발생하는 문제</th><th>이 축이 해결하는 것</th></tr></thead><tbody>')
|
||
body.append('<tr><td>"3열 병렬" 프레임끼리 구분 불가 — Frame 12 (목표 3축) 와 Frame 20 (S/W 필수 3관점) 둘 다 3열인데 의미 다름</td>'
|
||
'<td><code>content_affinity</code> 가 <code>goal_axes</code> vs <code>capability_requirements</code> 로 구분</td></tr>')
|
||
body.append('<tr><td>"compare-rows" 와 "table-2col" 이 다른 layout 처럼 보이지만 실제론 같은 2-way 비교</td>'
|
||
'<td><code>alternative_patterns</code> 에 명시 — 자동 대체 가능</td></tr>')
|
||
body.append('<tr><td>"혁신", "전환" 같은 단어가 과매칭 — 거의 모든 MDX 에 <code>state_transition</code> 붙음</td>'
|
||
'<td>STRONG 패턴(AS-IS/TO-BE/이중 변환) 없이는 인정 안 함 — validator 강화</td></tr>')
|
||
body.append('</tbody></table>')
|
||
|
||
return html_wrap('첨부 4 — 구조 매칭 키워드', ''.join(body))
|
||
|
||
|
||
# ============================================================
|
||
# ATTACH 05 — fit-v2 샘플 3건
|
||
# ============================================================
|
||
|
||
def build_attach05(v1, v2, v3_r5, v4_r2, descriptions):
|
||
body = []
|
||
body.append('<h1>첨부 5 — fit-v2 적합 샘플</h1>')
|
||
body.append('<div class="meta-bar">단순 구조 매칭이 아닌 template-fit-v2 판정이 필요한 이유를 3 가지 '
|
||
'상황(V1 강함 / V1 약함 / V1 강함 but 재구성 필요) 으로 보여줌.</div>')
|
||
|
||
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('<div class="avoid-break">')
|
||
lines.append(f'<h2>{case_label} — {sid} · {title}</h2>')
|
||
lines.append(f'<div class="mdx-card"><div class="mdx-excerpt">{excerpt}</div></div>')
|
||
|
||
lines.append('<table><thead><tr><th style="width:25%">축</th><th>값</th></tr></thead><tbody>')
|
||
lines.append(f'<tr><td>V1 점수 (키워드)</td><td><code>{v1_top["matching_score"]:.3f}</code> → Frame {v1_top["frame_number"]}</td></tr>')
|
||
lines.append(f'<tr><td>V4 1위</td><td>Frame {v4_top["frame_number"]} conf <code>{v4_top["confidence"]:.3f}</code> '
|
||
f'<span class="label-badge label-{v4_top["label"]}">{v4_top["label"]}</span></td></tr>')
|
||
lines.append(f'<tr><td>V4 axes 분해</td><td>'
|
||
f'anchor <code>{v4_top["axes"]["anchor"]}</code> · '
|
||
f'cardinality <code>{v4_top["axes"]["cardinality"]}</code> · '
|
||
f'relation <code>{v4_top["axes"]["relation"]}</code> · '
|
||
f'slot <code>{v4_top["axes"]["slot"]}</code> · '
|
||
f'content <code>{v4_top["axes"]["content"]}</code> · '
|
||
f'penalty <code>{v4_top["penalty"]}</code>'
|
||
f'</td></tr>')
|
||
lines.append('</tbody></table>')
|
||
|
||
# V4 top1 이미지
|
||
fn = v4_top['frame_number']
|
||
is_ans = (answer is not None and fn == answer)
|
||
inner = (f'<img src="{img_uri(fn)}" class="frame-thumb" style="max-width:60mm;margin:0 auto"/>'
|
||
f'<div class="frame-label">Frame {fn}{"" if not is_ans else " 🎯 (정답)"}</div>'
|
||
f'<div class="frame-desc">{descriptions.get(fn, "")}</div>')
|
||
if is_ans:
|
||
inner = f'<div class="answer-box" style="display:inline-block">{inner}</div>'
|
||
lines.append(f'<div style="text-align:center;margin:4pt 0">{inner}</div>')
|
||
|
||
lines.append(f'<div class="{analysis["note_type"]}">{analysis["note"]}</div>')
|
||
lines.append('</div>')
|
||
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': ('<strong>해석:</strong> V1 점수 <code>0.937</code> 으로 매우 강함. '
|
||
'V4 axes 도 모두 1.0 에 가깝고 label <span class="label-badge label-use_as_is">use_as_is</span>. '
|
||
'fit-v2 의 multi-axis 판정이 "이 프레임은 그대로 써도 된다" 를 명확히 확증. '
|
||
'V1 점수만 있어도 맞지만, V4 의 reject 여부 확인이 있어야 <strong>실사용 보장</strong>.'),
|
||
}))
|
||
|
||
# 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': ('<strong>해석:</strong> V1 점수 <code>0.598</code> 로 경계 이하(< 0.7). 키워드만 보면 '
|
||
'애매함. V4 의 content_affinity (<code>capability_requirements</code> 매칭) + '
|
||
'cardinality + relation + slot 종합 판정으로 Frame 13 이 <code>use_as_is</code> 로 확정. '
|
||
'fit-v2 가 <strong>V1 의 약한 신호를 보강</strong>해 정답에 도달.'),
|
||
}))
|
||
|
||
# Case 3: V1 강함, V4 reject — 쓸 수 없다는 신호
|
||
body.append(render_case('02-2.2', '사례 3 — V1 강함 + V4 reject (재구성 필요 신호)', {
|
||
'note_type': 'fail-note',
|
||
'note': ('<strong>해석:</strong> V1 점수 <code>0.840</code> 강하고 1위 Frame 14 는 정답 (사용자 검수). '
|
||
'그러나 V4 confidence 는 <code>0.293</code> → <span class="label-badge label-reject">reject</span>. '
|
||
'axes 분해에서 anchor 와 content 는 맞지만 cardinality/relation/slot 의 점수가 낮음. '
|
||
'즉 <strong>"정답으로 지목됐지만 그대로 쓰기엔 구조가 부족"</strong>. '
|
||
'fit-v2 가 <strong>점수 1위 ≠ 실사용 가능</strong>을 드러내는 핵심 사례. '
|
||
'단순 구조 매칭 (V3) 으로는 이 신호를 잡을 수 없음.'),
|
||
}))
|
||
|
||
body.append('<div class="note">⇒ fit-v2 는 <strong>정답 맞추기가 아니라 실사용 판정</strong>. '
|
||
'이것이 V3 단순 구조 매칭보다 필요한 이유.</div>')
|
||
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('<h1>첨부 6 — Figma DB 정리 방안 (Frame 12 예시)</h1>')
|
||
body.append('<div class="meta-bar">템플릿-fit 스키마 기반. 32 프레임 + BEPs 외 디자인들을 동일 스키마로 정리.</div>')
|
||
|
||
body.append('<div class="flex-grid avoid-break"><div class="flex-row">')
|
||
body.append('<div class="flex-cell" style="width:40%">')
|
||
body.append(f'<img src="{img_uri(12)}" class="frame-thumb" style="max-width:72mm"/>')
|
||
body.append(f'<div class="frame-label" style="margin-top:3pt">Frame 12 · {e["source"]["title"]}</div>')
|
||
body.append(f'<div class="frame-desc" style="font-size:8.5pt">{descriptions.get(12, "")}</div>')
|
||
body.append('</div>')
|
||
body.append('<div class="flex-cell" style="width:60%;padding-left:8pt">')
|
||
body.append('<h3 style="margin-top:0">visual_pattern</h3>')
|
||
body.append('<table>')
|
||
body.append(f'<tr><td style="width:35%"><code>layout</code></td><td><code>{vp["layout"]}</code></td></tr>')
|
||
body.append(f'<tr><td><code>family</code></td><td><code>{vp.get("family")}</code></td></tr>')
|
||
body.append(f'<tr><td><code>relation_type</code></td><td><code>{vp.get("relation_type")}</code></td></tr>')
|
||
card = vp.get("cardinality", {})
|
||
body.append(f'<tr><td><code>cardinality</code></td><td>ideal {card.get("ideal")} / min {card.get("min")} / max {card.get("max")}</td></tr>')
|
||
body.append('</table>')
|
||
body.append('</div></div></div>')
|
||
|
||
body.append('<h2>content_affinity · structure_intent · alternative_patterns</h2>')
|
||
body.append('<table>')
|
||
body.append(f'<tr><td style="width:28%"><code>content_affinity.primary</code></td><td><code>{aff["primary"]}</code></td></tr>')
|
||
body.append(f'<tr><td><code>content_affinity.secondary</code></td><td>' +
|
||
', '.join(f'<code>{s}</code>' for s in aff.get('secondary', []) or ['(없음)']) + '</td></tr>')
|
||
body.append(f'<tr><td><code>structure_intent.primary</code></td><td><code>{intent["primary"]}</code></td></tr>')
|
||
body.append(f'<tr><td><code>structure_intent.secondary</code></td><td>' +
|
||
', '.join(f'<code>{s}</code>' for s in intent.get('secondary', []) or ['(없음)']) + '</td></tr>')
|
||
body.append('</table>')
|
||
|
||
body.append('<h3>alternative_patterns</h3>')
|
||
body.append('<table><thead><tr><th>pattern</th><th>conf</th><th>source</th><th>reason</th></tr></thead><tbody>')
|
||
for a in alts:
|
||
body.append(f'<tr><td><code>{a["pattern"]}</code></td><td>{a["confidence"]}</td>'
|
||
f'<td><code>{a.get("source", "derived")}</code></td><td>{a["reason"]}</td></tr>')
|
||
body.append('</tbody></table>')
|
||
|
||
body.append('<h2>slots · suits · not_suits</h2>')
|
||
body.append('<div class="flex-grid"><div class="flex-row">')
|
||
body.append('<div class="flex-cell" style="width:55%">')
|
||
body.append('<h3>slots</h3>')
|
||
body.append('<table><thead><tr><th>id</th><th>type</th><th>required</th></tr></thead><tbody>')
|
||
for s in slots:
|
||
body.append(f'<tr><td><code>{s["id"]}</code></td><td>{s.get("type")}</td>'
|
||
f'<td>{s.get("required", False)}</td></tr>')
|
||
body.append('</tbody></table></div>')
|
||
body.append('<div class="flex-cell" style="width:45%;padding-left:6pt">')
|
||
body.append('<h3>suits · not_suits</h3>')
|
||
body.append('<div style="font-size:8.5pt"><strong>suits:</strong><ul style="padding-left:14pt;margin:2pt 0">')
|
||
for s in suits:
|
||
body.append(f'<li>{s}</li>')
|
||
body.append('</ul><strong>not_suits:</strong><ul style="padding-left:14pt;margin:2pt 0">')
|
||
for s in not_suits:
|
||
body.append(f'<li>{s}</li>')
|
||
body.append('</ul></div></div>')
|
||
body.append('</div></div>')
|
||
|
||
body.append('<h2 class="page-break">32 프레임 분포 + 정리 상태</h2>')
|
||
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('<div class="flex-grid"><div class="flex-row">')
|
||
body.append('<div class="flex-cell" style="width:50%">')
|
||
body.append('<h3>content_affinity 분포</h3>')
|
||
body.append('<table><thead><tr><th>primary</th><th style="width:20%">수</th></tr></thead><tbody>')
|
||
for k, c in aff_counter.most_common():
|
||
body.append(f'<tr><td><code>{k}</code></td><td>{c}</td></tr>')
|
||
body.append('</tbody></table></div>')
|
||
body.append('<div class="flex-cell" style="width:50%;padding-left:8pt">')
|
||
body.append('<h3>structure_intent 분포</h3>')
|
||
body.append('<table><thead><tr><th>primary</th><th style="width:20%">수</th></tr></thead><tbody>')
|
||
for k, c in intent_counter.most_common():
|
||
body.append(f'<tr><td><code>{k}</code></td><td>{c}</td></tr>')
|
||
body.append('</tbody></table></div>')
|
||
body.append('</div></div>')
|
||
|
||
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'<div class="note">총 <strong>32</strong> 프레임. AI 라벨 초안 → 키워드 튜닝 → '
|
||
f'사용자 검토 루프 → 수동 오버라이드 <strong>{mo}</strong> 건 / '
|
||
f'review_queue 잔여 <strong>{rv}</strong> 건 / resolved <strong>{res}</strong> 건. '
|
||
f'향후 BEPs 외 디자인 추가 시 동일 스키마로 확장. 테스트 섹션 늘려가며 조정 필요.</div>')
|
||
|
||
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()
|