Files
C.E.L_Slide_test2/src/step_visualizer.py

908 lines
42 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""파이프라인 각 Stage 실행 후 자동으로 HTML 시각화를 생성.
save_snapshot()에서 호출됨. 각 stage의 실제 context 데이터로 HTML 생성.
JSON context 파일과 동일한 stage 이름을 사용.
생성되는 파일 (JSON context 파일과 1:1 매칭):
stage_0.html — MDX 정규화 결과 (섹션, 팝업, 이미지)
stage_1a.html — Kei 꼭지 + 영역 배정 (테이블)
stage_1b.html — 컨셉 구체화 (source_data, summary 추가)
stage_1_5a.html — 빈 컨테이너 (1280x720)
stage_1_5a_content.html — 컨테이너에 실제 콘텐츠 배치
stage_1_5b.html — 디자인 예산 (영역별 available_height/width)
stage_1_7.html — 블록 선택 표시
stage_1_8_fit_before.html — 적합성 검증 (재배분 전)
stage_1_8_fit_after.html — 재배분 후 + 보강 결과
stage_1_8_blocks.html — 재배분 후 컨테이너에 블록 배치
stage_2.html — HTML 생성 결과 (영역별 생성된 HTML)
stage_3.html — 렌더링 조립 → final.html 링크
stage_4.html — 품질 게이트 (측정값, 점수)
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Any, TYPE_CHECKING
if TYPE_CHECKING:
from src.pipeline_context import PipelineContext
logger = logging.getLogger(__name__)
COLORS_A = {"배경": "#dc2626", "본심": "#2563eb", "첨부": "#16a34a", "결론": "#7c3aed"}
FONT_MAP_A = {"배경": "bg", "본심": "core", "첨부": "sidebar", "결론": "key_msg"}
# Type B용 동적 색상 팔레트
_COLOR_PALETTE = ["#2563eb", "#16a34a", "#d97706", "#7c3aed", "#dc2626", "#0891b2"]
# 하위 호환: 기존 코드에서 COLORS/FONT_MAP 참조하는 곳 대응
COLORS = COLORS_A
FONT_MAP = FONT_MAP_A
def _is_type_b(ctx) -> bool:
"""page_structure에 zone 키가 있으면 Type B."""
ps = ctx.page_structure.roles if hasattr(ctx.page_structure, 'roles') else {}
for info in ps.values():
if isinstance(info, dict) and info.get("zone") in ("top", "bottom_left", "bottom_right"):
return True
return False
def _get_roles(ctx) -> list[str]:
"""page_structure의 실제 역할명 목록 (순서: zone 기준)."""
ps = ctx.page_structure.roles if hasattr(ctx.page_structure, 'roles') else {}
if _is_type_b(ctx):
zone_order = {"top": 0, "bottom_left": 1, "bottom_right": 2, "footer": 3}
roles = []
for role_name, info in ps.items():
if isinstance(info, dict):
z = info.get("zone", "")
roles.append((zone_order.get(z, 9), role_name))
return [r for _, r in sorted(roles)]
else:
return ["배경", "본심", "첨부", "결론"]
def _get_color(role: str, ctx=None) -> str:
"""역할명 → 색상. Type A는 고정, Type B는 동적."""
if role in COLORS_A:
return COLORS_A[role]
if ctx:
roles = _get_roles(ctx)
idx = roles.index(role) if role in roles else 0
return _COLOR_PALETTE[idx % len(_COLOR_PALETTE)]
return "#666666"
def generate_step_html(stage_name: str, ctx: "PipelineContext", steps_dir: Path) -> None:
"""stage_name에 따라 적절한 시각화 HTML 생성. 파일명은 JSON context와 1:1 매칭."""
try:
if stage_name == "stage_0":
_gen_stage_0(ctx, steps_dir)
elif stage_name == "stage_1a":
_gen_stage_1a(ctx, steps_dir)
elif stage_name == "stage_1b":
_gen_stage_1b(ctx, steps_dir)
elif stage_name == "stage_1_5a":
_gen_stage_1_5a(ctx, steps_dir)
_gen_stage_1_5a_content(ctx, steps_dir)
elif stage_name == "stage_1_5b":
_gen_stage_1_5b(ctx, steps_dir)
elif stage_name == "stage_1_7":
_gen_stage_1_7(ctx, steps_dir)
elif stage_name == "stage_1_8":
# before와 filled는 pipeline.py Stage 1.8 내부에서 before context로 이미 생성됨
# step_visualizer는 after context로 호출되므로 덮어쓰면 안 됨
# blocks와 fit_after만 생성 (after 상태 반영)
_gen_stage_1_8_blocks(ctx, steps_dir)
_gen_stage_1_8_fit_after(ctx, steps_dir)
elif stage_name == "stage_2":
_gen_stage_2(ctx, steps_dir)
elif stage_name == "stage_3":
_gen_stage_3(ctx, steps_dir)
elif stage_name == "stage_4":
_gen_stage_4(ctx, steps_dir)
except Exception as e:
logger.warning(f"[step_viz] {stage_name} 시각화 실패: {e}")
# ══════════════════════════════════════
# 공통
# ══════════════════════════════════════
def _tokens():
from src.fit_verifier import _load_design_tokens
return _load_design_tokens()
def _calc_coords(containers: dict, ratio: tuple, ctx=None) -> dict:
"""역할별 좌표 계산. Type A/B 자동 분기."""
t = _tokens()
pad = t.get("spacing_page", 40)
gap = t.get("spacing_block", 20)
small = t.get("spacing_small", 8)
header_h = 66
inner_w = 1280 - pad * 2
def gh(c):
if hasattr(c, "height_px"): return c.height_px
return c.get("height_px", 0) if isinstance(c, dict) else 0
def gw(c):
if hasattr(c, "width_px"): return c.width_px
return c.get("width_px", 0) if isinstance(c, dict) else 0
# Type B 감지
if ctx and _is_type_b(ctx):
ps = ctx.page_structure.roles
coords = {"header": {"l": pad, "t": pad, "w": inner_w, "h": header_h}}
# zone별 컨테이너 찾기
zone_map = {}
for role_name, info in ps.items():
if isinstance(info, dict):
zone_map[info.get("zone", "")] = role_name
top_role = zone_map.get("top", "")
bl_role = zone_map.get("bottom_left", "")
br_role = zone_map.get("bottom_right", "")
ft_role = zone_map.get("footer", "")
top_h = gh(containers.get(top_role, {}))
bl_h = gh(containers.get(bl_role, {}))
br_h = gh(containers.get(br_role, {}))
ft_h = gh(containers.get(ft_role, {}))
top_top = pad + header_h + gap
bottom_top = top_top + top_h + small
bottom_h = max(bl_h, br_h)
ft_top = bottom_top + bottom_h + gap
bottom_col_w = (inner_w - gap) // 2
if top_role:
coords[top_role] = {"l": pad, "t": top_top, "w": inner_w, "h": top_h}
if bl_role:
coords[bl_role] = {"l": pad, "t": bottom_top, "w": bottom_col_w, "h": bottom_h}
if br_role:
coords[br_role] = {"l": pad + bottom_col_w + gap, "t": bottom_top, "w": bottom_col_w, "h": bottom_h}
if ft_role:
coords[ft_role] = {"l": pad, "t": ft_top, "w": inner_w, "h": ft_h}
return coords
# Type A (기존)
body_w = int(inner_w * ratio[0] / 100) if ratio[0] > 0 else inner_w
sidebar_w = inner_w - body_w - gap if ratio[1] > 0 else 0
bg_h = gh(containers.get("배경", {}))
core_h = gh(containers.get("본심", {}))
sb_h = gh(containers.get("첨부", {}))
ft_h = gh(containers.get("결론", {}))
bg_top = pad + header_h + gap
core_top = bg_top + bg_h + small
ft_top = max(core_top + core_h, bg_top + sb_h) + gap
return {
"header": {"l": pad, "t": pad, "w": inner_w, "h": header_h},
"배경": {"l": pad, "t": bg_top, "w": body_w, "h": bg_h},
"본심": {"l": pad, "t": core_top, "w": body_w, "h": core_h},
"첨부": {"l": pad + body_w + gap, "t": bg_top, "w": sidebar_w, "h": sb_h},
"결론": {"l": pad, "t": ft_top, "w": inner_w, "h": ft_h},
}
def _wrap(title, subtitle, slide_body, ctx=None):
"""slide-base.html 기반 래핑. step 시각화도 실제 슬라이드와 같은 기반 사용."""
from pathlib import Path
slide_base_path = Path(__file__).parent.parent / "templates" / "blocks" / "slide-base.html"
slide_title = ""
footer_text = ""
if ctx:
slide_title = ctx.analysis.title if ctx.analysis else ""
footer_text = ctx.analysis.conclusion_text if ctx.analysis else ""
try:
raw = slide_base_path.read_text(encoding="utf-8")
# {% block body %} → slide_body로 치환
raw = raw.replace("{% block body %}{% endblock %}", slide_body)
from jinja2 import Template
template = Template(raw)
slide_html = template.render(title=slide_title, footer_text=footer_text, footer_pill_bg="")
# step 라벨 추가
label = (f'<div style="position:fixed;top:4px;left:4px;z-index:999;font-size:14px;'
f'font-weight:bold;background:rgba(255,255,255,0.9);padding:4px 8px;border-radius:4px;">'
f'{title}</div>'
f'<div style="position:fixed;top:26px;left:4px;z-index:999;font-size:10px;'
f'color:#666;background:rgba(255,255,255,0.9);padding:2px 8px;border-radius:4px;">'
f'{subtitle}</div>')
return slide_html.replace('<body>', f'<body>{label}')
except Exception:
# fallback: 기존 방식
return f"""<!DOCTYPE html><html><head><meta charset="UTF-8">
<style>
*{{margin:0;padding:0;box-sizing:border-box;}}
body{{background:#e5e5e5;padding:10px;font-family:'Pretendard Variable','Noto Sans KR',sans-serif;word-break:keep-all;}}
</style></head><body>
<div style="font-size:16px;font-weight:bold;margin-bottom:4px;">{title}</div>
<div style="font-size:11px;color:#666;margin-bottom:8px;">{subtitle}</div>
<div style="width:1280px;height:720px;background:white;position:relative;border:1px solid #ccc;">
{slide_body}
</div></body></html>"""
def _hdr(c, title):
return (f'<div style="position:absolute;left:{c["l"]}px;top:{c["t"]}px;width:{c["w"]}px;height:{c["h"]}px;'
f'background:#f8fafc;border-bottom:3px solid #2563eb;display:flex;align-items:center;'
f'padding:0 20px;font-size:22px;font-weight:900;color:#1e293b;">{title}</div>\n')
def _box(c, role, inner, extra=""):
cl = COLORS.get(role, "#333")
return (f'<div style="position:absolute;left:{c["l"]}px;top:{c["t"]}px;width:{c["w"]}px;height:{c["h"]}px;'
f'border:2px solid {cl};border-radius:6px;background:{cl}08;overflow:hidden;{extra}">{inner}</div>\n')
# ══════════════════════════════════════
# Stage 0: MDX 정규화
# ══════════════════════════════════════
def _gen_stage_0(ctx, steps_dir):
"""MDX 정규화 결과: 섹션, 팝업, 이미지, 테이블 목록."""
norm = ctx.normalized if hasattr(ctx, 'normalized') else {}
if hasattr(norm, 'model_dump'):
norm = norm.model_dump()
elif not isinstance(norm, dict):
norm = {}
sections = norm.get("sections", [])
popups = norm.get("popups", [])
images = norm.get("images", [])
tables = norm.get("tables", [])
title = norm.get("title", ctx.analysis.title if ctx.analysis else "")
sec_rows = ""
for i, s in enumerate(sections):
heading = s.get("heading", "") if isinstance(s, dict) else ""
content = s.get("content", "") if isinstance(s, dict) else str(s)
preview = content[:120].replace("<", "&lt;") + ("..." if len(content) > 120 else "")
bg = "#f8fafc" if i % 2 == 0 else "#fff"
sec_rows += f'<tr style="background:{bg};"><td style="padding:6px 8px;">{i+1}</td><td style="padding:6px 8px;font-weight:700;">{heading}</td><td style="padding:6px 8px;font-size:11px;">{preview}</td></tr>\n'
popup_rows = ""
for p in popups:
pt = p.get("title", "") if isinstance(p, dict) else str(p)
pc = p.get("content", "") if isinstance(p, dict) else ""
popup_rows += f'<tr><td style="padding:6px 8px;font-weight:700;">{pt}</td><td style="padding:6px 8px;font-size:11px;">{len(pc)}자</td></tr>\n'
html = f"""<!DOCTYPE html><html><head><meta charset="UTF-8">
<style>*{{margin:0;padding:0;box-sizing:border-box;}}body{{background:#e5e5e5;padding:10px;font-family:sans-serif;word-break:keep-all;}}</style>
</head><body>
<div style="font-size:16px;font-weight:bold;margin-bottom:8px;">Stage 0: MDX 정규화</div>
<div style="font-size:12px;color:#555;margin-bottom:12px;">제목: <b>{title}</b> | 섹션: {len(sections)}개 | 팝업: {len(popups)}개 | 이미지: {len(images)}개 | 테이블: {len(tables)}개</div>
<div style="font-size:13px;font-weight:700;margin-bottom:4px;">섹션</div>
<table style="border-collapse:collapse;font-size:12px;width:100%;max-width:900px;margin-bottom:16px;">
<tr style="background:#1e293b;color:white;"><th style="padding:8px;">#</th><th style="padding:8px;">heading</th><th style="padding:8px;">content (미리보기)</th></tr>{sec_rows}</table>
<div style="font-size:13px;font-weight:700;margin-bottom:4px;">팝업</div>
<table style="border-collapse:collapse;font-size:12px;width:100%;max-width:600px;">
<tr style="background:#1e293b;color:white;"><th style="padding:8px;">title</th><th style="padding:8px;">분량</th></tr>{popup_rows}</table>
</body></html>"""
(steps_dir / "stage_0.html").write_text(html, encoding="utf-8")
# ══════════════════════════════════════
# Stage 1A: Kei 꼭지
# ══════════════════════════════════════
def _gen_stage_1a(ctx, steps_dir):
ps = ctx.page_structure.roles
rm = {}
for role, info in ps.items():
if isinstance(info, dict):
for tid in info.get("topic_ids", []):
rm[tid] = role
rows = ""
for t in ctx.topics:
role = rm.get(t.id, "?")
c = COLORS.get(role, "#333")
bg = "#f8fafc" if t.id % 2 == 0 else "#fff"
rows += (f'<tr style="background:{bg};"><td style="padding:6px 8px;text-align:center;">{t.id}</td>'
f'<td style="padding:6px 8px;font-weight:700;">{t.title}</td>'
f'<td style="padding:6px 8px;">{t.purpose}</td><td style="padding:6px 8px;">{t.layer}</td>'
f'<td style="padding:6px 8px;">{t.relation_type}</td>'
f'<td style="padding:6px 8px;color:{c};font-weight:700;">{role}</td></tr>\n')
ps_info = "<br>".join(f"{r}: topic_ids={info.get('topic_ids')}, weight={info.get('weight')}"
for r, info in ps.items() if isinstance(info, dict))
html = f"""<!DOCTYPE html><html><head><meta charset="UTF-8">
<style>*{{margin:0;padding:0;box-sizing:border-box;}}body{{background:#e5e5e5;padding:10px;font-family:sans-serif;word-break:keep-all;}}</style>
</head><body>
<div style="font-size:16px;font-weight:bold;margin-bottom:8px;">Stage 1A/1B: Kei 꼭지 + 영역 배정</div>
<table style="border-collapse:collapse;font-size:12px;width:100%;max-width:900px;">
<tr style="background:#1e293b;color:white;"><th style="padding:8px;">ID</th><th style="padding:8px;">제목</th>
<th style="padding:8px;">purpose</th><th style="padding:8px;">layer</th><th style="padding:8px;">relation_type</th>
<th style="padding:8px;">영역</th></tr>{rows}</table>
<div style="margin-top:12px;font-size:12px;color:#555;"><b>페이지 구조:</b><br>{ps_info}</div></body></html>"""
(steps_dir / "stage_1a.html").write_text(html, encoding="utf-8")
# ══════════════════════════════════════
# Stage 1B: 컨셉 구체화
# ══════════════════════════════════════
def _gen_stage_1b(ctx, steps_dir):
"""Stage 1B 후 꼭지에 source_data, summary가 추가된 상태."""
ps = ctx.page_structure.roles
rm = {}
for role, info in ps.items():
if isinstance(info, dict):
for tid in info.get("topic_ids", []):
rm[tid] = role
rows = ""
for t in ctx.topics:
role = rm.get(t.id, "?")
c = COLORS.get(role, "#333")
bg = "#f8fafc" if t.id % 2 == 0 else "#fff"
sd = (t.source_data or "")[:150]
sd_display = sd.replace("<", "&lt;") + ("..." if len(t.source_data or "") > 150 else "")
summary = (t.summary or "")[:100] if hasattr(t, 'summary') else ""
rows += (f'<tr style="background:{bg};"><td style="padding:6px 8px;text-align:center;">{t.id}</td>'
f'<td style="padding:6px 8px;font-weight:700;">{t.title}</td>'
f'<td style="padding:6px 8px;color:{c};">{role}</td>'
f'<td style="padding:6px 8px;">{t.layer}</td>'
f'<td style="padding:6px 8px;font-size:10px;">{sd_display}</td>'
f'<td style="padding:6px 8px;font-size:10px;color:#555;">{summary}</td></tr>\n')
html = f"""<!DOCTYPE html><html><head><meta charset="UTF-8">
<style>*{{margin:0;padding:0;box-sizing:border-box;}}body{{background:#e5e5e5;padding:10px;font-family:sans-serif;word-break:keep-all;}}</style>
</head><body>
<div style="font-size:16px;font-weight:bold;margin-bottom:8px;">Stage 1B: 컨셉 구체화</div>
<div style="font-size:11px;color:#666;margin-bottom:8px;">Stage 1A의 꼭지에 source_data(원본 텍스트)와 summary가 추가됨</div>
<table style="border-collapse:collapse;font-size:12px;width:100%;">
<tr style="background:#1e293b;color:white;"><th style="padding:8px;">ID</th><th style="padding:8px;">제목</th>
<th style="padding:8px;">영역</th><th style="padding:8px;">layer</th>
<th style="padding:8px;">source_data (미리보기)</th><th style="padding:8px;">summary</th></tr>{rows}</table></body></html>"""
(steps_dir / "stage_1b.html").write_text(html, encoding="utf-8")
# ══════════════════════════════════════
# Stage 1.5a: 빈 컨테이너
# ══════════════════════════════════════
def _gen_stage_1_5a(ctx, steps_dir):
"""slide-base 위에 빈 zone 컨테이너만 표시."""
ps = ctx.page_structure.roles
gap = 8
# zone 순서
zone_priority = {"top": 0, "bottom": 1, "bottom_left": 2, "bottom_right": 3}
roles_sorted = sorted(
[(r, info) for r, info in ps.items() if isinstance(info, dict)],
key=lambda x: zone_priority.get(x[1].get("zone", ""), 9),
)
body_html = ""
for i, (role, info) in enumerate(roles_sorted):
ci = ctx.containers.get(role)
if not ci:
continue
cl = _get_color(role, ctx)
zone = info.get("zone", "")
w = ci.width_px
h = ci.height_px
tids = info.get("topic_ids", [])
body_html += (
f'<div style="width:{w}px;height:{h}px;border:2px dashed {cl};border-radius:6px;'
f'margin-bottom:{gap}px;display:flex;align-items:center;justify-content:center;">'
f'<div style="text-align:center;">'
f'<b style="color:{cl};font-size:14px;">{role}</b><br>'
f'<span style="color:#888;font-size:11px;">zone: {zone} / {w}×{h}px</span><br>'
f'<span style="color:#aaa;font-size:10px;">topics: {tids}</span>'
f'</div></div>\n'
)
html = _wrap("Stage 1.5a: 빈 컨테이너", "slide-base 위에 zone 배치", body_html, ctx=ctx)
(steps_dir / "stage_1_5a.html").write_text(html, encoding="utf-8")
# ══════════════════════════════════════
# Stage 1.5a: 컨테이너에 콘텐츠 배치
# ══════════════════════════════════════
def _gen_stage_1_5a_content(ctx, steps_dir):
"""slide-base 위 zone에 topic 콘텐츠 배치."""
ps = ctx.page_structure.roles
topic_map = {t.id: t for t in ctx.topics}
gap = 8
zone_priority = {"top": 0, "bottom": 1, "bottom_left": 2, "bottom_right": 3}
roles_sorted = sorted(
[(r, info) for r, info in ps.items() if isinstance(info, dict)],
key=lambda x: zone_priority.get(x[1].get("zone", ""), 9),
)
body_html = ""
for role, info in roles_sorted:
ci = ctx.containers.get(role)
if not ci:
continue
cl = _get_color(role, ctx)
tids = info.get("topic_ids", [])
w = ci.width_px
h = ci.height_px
lines = [f'<div style="font-size:10px;color:{cl};font-weight:700;margin-bottom:4px;">{role} ({w}×{h}px)</div>']
for tid in tids:
t = topic_map.get(tid)
if not t:
continue
lines.append(f'<div style="font-size:11px;font-weight:700;margin-bottom:2px;">[꼭지{tid}] {t.title}{t.purpose} · {t.layer}</div>')
sd = t.source_data
if sd:
for sent in sd.split(", ")[:5]:
sent = sent.strip()
if sent:
lines.append(f'<div style="font-size:10px;color:#444;padding-left:12px;">• {sent}</div>')
body_html += (
f'<div style="width:{w}px;height:{h}px;border:2px solid {cl};border-radius:6px;'
f'margin-bottom:{gap}px;padding:6px 10px;overflow:hidden;">'
f'{"".join(lines)}</div>\n'
)
html = _wrap("Stage 1.5a: 콘텐츠 배치", "zone별 topic source_data", body_html, ctx=ctx)
(steps_dir / "stage_1_5a_content.html").write_text(html, encoding="utf-8")
# ══════════════════════════════════════
# Stage 1.5b: 디자인 예산
# ══════════════════════════════════════
def _gen_stage_1_5b(ctx, steps_dir):
"""slide-base 위 zone별 디자인 예산."""
ps = ctx.page_structure.roles
gap = 8
zone_priority = {"top": 0, "bottom": 1, "bottom_left": 2, "bottom_right": 3}
roles_sorted = sorted(
[(r, info) for r, info in ps.items() if isinstance(info, dict)],
key=lambda x: zone_priority.get(x[1].get("zone", ""), 9),
)
body_html = ""
for role, info in roles_sorted:
ci = ctx.containers.get(role)
if not ci:
continue
cl = _get_color(role, ctx)
w, h = ci.width_px, ci.height_px
db = ci.design_budget
if db and hasattr(db, 'model_dump'):
db = db.model_dump()
elif not isinstance(db, dict):
db = {}
avail_h = db.get("available_height_px", 0)
avail_w = db.get("available_width_px", 0)
fits = db.get("fits", False)
icon = "" if fits else "⚠️"
body_html += (
f'<div style="width:{w}px;height:{h}px;border:2px solid {cl};border-radius:6px;'
f'margin-bottom:{gap}px;padding:6px 10px;overflow:hidden;">'
f'<div style="font-size:11px;color:{cl};font-weight:700;">{icon} {role} ({w}×{h}px)</div>'
f'<div style="font-size:10px;color:#555;">available: {avail_h}×{avail_w}px / fits: {fits}</div>'
f'</div>\n'
)
html = _wrap("Stage 1.5b: 디자인 예산", "영역별 available_height/width + fits 여부", body_html, ctx=ctx)
(steps_dir / "stage_1_5b.html").write_text(html, encoding="utf-8")
# ══════════════════════════════════════
# Stage 1.7: 블록 선택
# ══════════════════════════════════════
def _gen_stage_1_7(ctx, steps_dir):
"""slide-base 위 zone별 선택된 블록 표시."""
ps = ctx.page_structure.roles
gap = 8
zone_priority = {"top": 0, "bottom": 1, "bottom_left": 2, "bottom_right": 3}
roles_sorted = sorted(
[(r, info) for r, info in ps.items() if isinstance(info, dict)],
key=lambda x: zone_priority.get(x[1].get("zone", ""), 9),
)
body_html = ""
for role, info in roles_sorted:
ci = ctx.containers.get(role)
if not ci:
continue
cl = _get_color(role, ctx)
w, h = ci.width_px, ci.height_px
ref_list = ctx.references.get(role, [])
lines = [f'<div style="font-size:11px;color:{cl};font-weight:700;margin-bottom:4px;">{role} ({w}×{h}px)</div>']
for r in ref_list:
vtype_label = "tag_match ✅" if r.visual_type == "tag_match" else r.visual_type
lines.append(f'<div style="font-size:12px;margin-bottom:2px;"><b>{r.block_id}</b> ({r.variant}) — {vtype_label}</div>')
body_html += (
f'<div style="width:{w}px;height:{h}px;border:2px solid {cl};border-radius:6px;'
f'margin-bottom:{gap}px;padding:6px 10px;overflow:hidden;">'
f'{"".join(lines)}</div>\n'
)
html = _wrap("Stage 1.7: 블록 선택", "tag 매칭 기반 블록 선택", body_html, ctx=ctx)
(steps_dir / "stage_1_7.html").write_text(html, encoding="utf-8")
# ══════════════════════════════════════
# Stage 1.8: 텍스트/그림 채운 상태 (filled)
# ══════════════════════════════════════
def _gen_stage_1_8_filled(ctx, steps_dir):
"""블록 디자인에 텍스트를 채운 상태. 공통 조립 함수(block_assembler) 사용."""
from src.block_assembler import assemble_slide_html
slide_html = assemble_slide_html(ctx)
# 시각화 제목 삽입
header = (
'<div style="font-size:16px;font-weight:bold;margin-bottom:4px;">'
'Stage 1.8: 블록 디자인에 텍스트 채운 상태 (fit 검증 전)</div>\n'
'<div style="font-size:11px;color:#666;margin-bottom:8px;">'
'블록 CSS + structured_text + font_hierarchy. 공통 조립 함수 사용.</div>\n'
)
html = slide_html.replace('</head><body>', '</head><body>\n' + header, 1)
(steps_dir / "stage_1_8_filled.html").write_text(html, encoding="utf-8")
def _gen_stage_1_8_fit_before(ctx, steps_dir):
"""slide-base 위 zone별 초기 배정 (weight 기반)."""
ps = ctx.page_structure.roles
gap = 8
zone_priority = {"top": 0, "bottom": 1, "bottom_left": 2, "bottom_right": 3}
roles_sorted = sorted(
[(r, info) for r, info in ps.items() if isinstance(info, dict)],
key=lambda x: zone_priority.get(x[1].get("zone", ""), 9),
)
body_html = ""
for role, info in roles_sorted:
ci = ctx.containers.get(role)
if not ci:
continue
cl = _get_color(role, ctx)
w, h = ci.width_px, ci.height_px
weight = info.get("weight", 0)
ref_list = ctx.references.get(role, [])
blocks = ", ".join(r.block_id for r in ref_list) if ref_list else "미선택"
body_html += (
f'<div style="width:{w}px;height:{h}px;border:2px dashed {cl};border-radius:6px;'
f'margin-bottom:{gap}px;padding:6px 10px;overflow:hidden;">'
f'<div style="font-size:11px;color:{cl};font-weight:700;">{role} ({w}×{h}px)</div>'
f'<div style="font-size:10px;color:#555;">weight: {weight} / 블록: {blocks}</div>'
f'</div>\n'
)
html = _wrap("Stage 1.8: before", "weight 비중 초기 배정. 블록 채움 전.", body_html, ctx=ctx)
(steps_dir / "stage_1_8_fit_before.html").write_text(html, encoding="utf-8")
# ══════════════════════════════════════
# Stage 1.8: 재배분 후 + 보강
# ══════════════════════════════════════
def _gen_stage_1_8_fit_after(ctx, steps_dir):
"""slide-base 위 zone별 재배분 결과."""
ps = ctx.page_structure.roles
fit = ctx.fit_result or {}
enh = ctx.enhancement_result or {}
redist = fit.get("redistribution", {})
roles_fit = fit.get("roles", {})
gap = 8
zone_priority = {"top": 0, "bottom": 1, "bottom_left": 2, "bottom_right": 3}
roles_sorted = sorted(
[(r, info) for r, info in ps.items() if isinstance(info, dict)],
key=lambda x: zone_priority.get(x[1].get("zone", ""), 9),
)
body_html = ""
for role, info in roles_sorted:
ci = ctx.containers.get(role)
if not ci:
continue
cl = _get_color(role, ctx)
w = ci.width_px
old_h = ci.height_px
new_h = int(redist.get(role, old_h))
rf = roles_fit.get(role, {})
status = rf.get("fit_status", "OK")
icon = {"OK": "", "TIGHT": "⚠️", "OVERFLOW": ""}.get(status, "")
delta = new_h - old_h
delta_str = f" ({delta:+d}px)" if delta != 0 else ""
ref_list = ctx.references.get(role, [])
blocks = ", ".join(r.block_id for r in ref_list) if ref_list else ""
body_html += (
f'<div style="width:{w}px;height:{new_h}px;border:2px solid {cl};border-radius:6px;'
f'margin-bottom:{gap}px;padding:6px 10px;overflow:hidden;">'
f'<div style="font-size:11px;color:{cl};font-weight:700;">{icon} {role} ({w}×{new_h}px){delta_str}</div>'
f'<div style="font-size:10px;color:#555;">블록: {blocks}</div>'
f'</div>\n'
)
redist_str = ", ".join(f"{r}:{int(v)}px" for r, v in redist.items()) if redist else "재배분 없음"
html = _wrap("Stage 1.8: 재배분 후", f"재배분: {redist_str}", body_html, ctx=ctx)
(steps_dir / "stage_1_8_fit_after.html").write_text(html, encoding="utf-8")
# ══════════════════════════════════════
# Stage 1.8: 블록 디자인을 컨테이너에 배치
# ══════════════════════════════════════
def _gen_stage_1_8_blocks(ctx, steps_dir):
"""slide-base 위에 실제 블록 렌더링. assemble_slide_html_final과 동일한 결과."""
from src.block_assembler import assemble_slide_html_final
html = assemble_slide_html_final(ctx)
(steps_dir / "stage_1_8_blocks.html").write_text(html, encoding="utf-8")
def _gen_stage_2(ctx, steps_dir):
"""Stage 2 결과: 영역별 HTML 생성 결과.
Type A: Sonnet 영역별 출력. Type B: slide-base 완전 HTML."""
gen = ctx.generated_html or {}
sub_layouts = ctx.sub_layouts or {}
ps = ctx.page_structure.roles
# Type B: generated_html이 str (완전한 HTML)
if isinstance(gen, str):
html = f"""<!DOCTYPE html><html><head><meta charset="UTF-8">
<style>*{{margin:0;padding:0;box-sizing:border-box;}}
body{{background:#e5e5e5;padding:10px;font-family:'Pretendard Variable','Noto Sans KR',sans-serif;}}</style>
</head><body>
<div style="font-size:16px;font-weight:bold;margin-bottom:4px;">Stage 2: slide-base + 블록 템플릿 조립 결과 (Type B)</div>
<div style="font-size:11px;color:#666;margin-bottom:12px;">slide-base.html 배경 위에 블록 템플릿으로 조립된 최종 HTML</div>
<iframe srcdoc="{gen.replace('"', '&quot;')}" style="width:1280px;height:720px;border:1px solid #ccc;"></iframe>
</body></html>"""
(steps_dir / "stage_2.html").write_text(html, encoding="utf-8")
return
# Type A: dict (body_html, sidebar_html, footer_html)
import re as _re
body_html = gen.get("body_html", "")
sidebar_html = gen.get("sidebar_html", "")
footer_html = gen.get("footer_html", "")
spacer_pattern = r'<div style="height:\d+px;"></div>'
body_parts = _re.split(spacer_pattern, body_html, maxsplit=1)
bg_html = body_parts[0].strip() if len(body_parts) > 1 else ""
core_html = body_parts[1].strip() if len(body_parts) > 1 else body_html.strip()
role_htmls = {}
if bg_html and "배경" in ps:
role_htmls["배경"] = bg_html
if core_html and "본심" in ps:
role_htmls["본심"] = core_html
if sidebar_html and "첨부" in ps:
role_htmls["첨부"] = sidebar_html
if footer_html and "결론" in ps:
role_htmls["결론"] = footer_html
# 각 역할을 컨테이너 크기에 맞게 실제 렌더링
fit = ctx.fit_result or {}
redist = fit.get("redistribution", {})
sections = []
for role in _get_roles(ctx):
rhtml = role_htmls.get(role, "")
if not rhtml:
continue
cl = _get_color(role, ctx)
ci = ctx.containers.get(role)
if not ci:
continue
h = int(redist.get(role, ci.height_px))
w = ci.width_px
# sub_layout 정보
layout = sub_layouts.get(role, {})
scs = layout.get("sub_containers", [])
sc_desc = " + ".join(f'{sc["name"]}({int(sc["width_px"])}×{int(sc["height_px"])}px)' for sc in scs) if scs else ""
sections.append(
f'<div style="margin-bottom:20px;">'
f'<div style="font-size:13px;font-weight:700;color:{cl};margin-bottom:4px;">'
f'{role} ({w}×{h}px)'
f'{"" + sc_desc if sc_desc else ""}</div>'
f'<div style="width:{w}px;height:{h}px;border:2px solid {cl};border-radius:6px;'
f'overflow:hidden;background:white;font-family:Pretendard Variable,sans-serif;'
f'word-break:keep-all;">{rhtml}</div></div>'
)
html = f"""<!DOCTYPE html><html><head><meta charset="UTF-8">
<style>*{{margin:0;padding:0;box-sizing:border-box;}}
body{{background:#e5e5e5;padding:10px;font-family:'Pretendard Variable','Noto Sans KR',sans-serif;word-break:keep-all;}}
:root{{--radius:6px;--line-height-ko:1.7;--color-accent:#2563eb;--color-primary:#1e293b;}}</style>
</head><body>
<div style="font-size:16px;font-weight:bold;margin-bottom:4px;">Stage 2: 영역별 HTML 생성 결과 (Sonnet)</div>
<div style="font-size:11px;color:#666;margin-bottom:12px;">각 역할의 Sonnet 출력을 컨테이너 크기에 맞게 실제 렌더링</div>
{"".join(sections)}
</body></html>"""
(steps_dir / "stage_2.html").write_text(html, encoding="utf-8")
def _gen_stage_3(ctx, steps_dir):
"""Stage 3 결과: rendered_html을 별도 파일로 저장 + 링크.
rendered_html 자체가 완성 HTML이므로 직접 렌더링 가능."""
rendered = ctx.rendered_html or ""
if rendered:
# rendered_html을 별도 파일로 저장하여 브라우저에서 직접 확인 가능
(steps_dir / "stage_3_rendered.html").write_text(rendered, encoding="utf-8")
html = f"""<!DOCTYPE html><html><head><meta charset="UTF-8">
<style>*{{margin:0;padding:0;box-sizing:border-box;}}
body{{background:#e5e5e5;padding:10px;font-family:sans-serif;}}</style>
</head><body>
<div style="font-size:16px;font-weight:bold;margin-bottom:4px;">Stage 3: 렌더링 조립 결과</div>
<div style="font-size:11px;color:#666;margin-bottom:8px;">Stage 2의 영역별 HTML을 슬라이드 프레임(CSS Grid)에 배치 + 후처리 적용</div>
<p style="margin-bottom:8px;"><a href="stage_3_rendered.html" style="font-size:16px;font-weight:700;">렌더링 결과 보기 (1280×720) →</a></p>
<p><a href="../final.html" style="font-size:14px;">final.html 보기 →</a></p>
<div style="margin-top:16px;font-size:12px;color:#555;">
Stage 3 후처리: sidebar width:100% 조정, 폰트 캡핑 (배경≤{ctx.font_hierarchy.bg}px, 첨부≤{ctx.font_hierarchy.sidebar}px), overflow 제거, bold 변환
</div>
</body></html>"""
(steps_dir / "stage_3.html").write_text(html, encoding="utf-8")
# ══════════════════════════════════════
# Stage 4: 품질 게이트
# ══════════════════════════════════════
def _gen_structure_validation(ctx) -> str:
"""sample-based 구조 검증. "어긋나면 안 된다" 기준."""
import re as _re
checks = []
html = ctx.rendered_html if hasattr(ctx, 'rendered_html') else ""
ps = ctx.page_structure.roles if hasattr(ctx.page_structure, 'roles') else {}
# 1. 본문 텍스트 visible (body에 실제 텍스트가 있는지)
body_start = html.find('<body') if html else -1
body_text = _re.sub(r'<[^>]+>', '', html[body_start:]) if body_start > 0 else ""
body_text = _re.sub(r'\s+', ' ', body_text).strip()
text_len = len(body_text)
ok = text_len > 100
checks.append(("본문 텍스트 visible", f"{'' if ok else ''} {text_len}"))
# 2. detail link 개수 (role당 1개)
link_count = len(_re.findall(r'자세히보기', html)) if html else 0
popup_count = len(ctx.normalized.popups) if hasattr(ctx.normalized, 'popups') else 0
ok = link_count <= max(popup_count, 1)
checks.append(("detail link 개수", f"{'' if ok else '⚠️'} {link_count}개 (popup {popup_count}개)"))
# 3. body 안 <style> 0개
body_styles = len(_re.findall(r'<style', html[body_start:])) if body_start > 0 else -1
ok = body_styles == 0
checks.append(("body 안 &lt;style&gt;", f"{'' if ok else ''} {body_styles}"))
# 4. conclusion * 없음
ct = ctx.analysis.conclusion_text if hasattr(ctx.analysis, 'conclusion_text') else ""
ok = not ct.startswith("*")
checks.append(("conclusion 선행 * 없음", f"{'' if ok else ''} \"{ct[:30]}\""))
# 5. sub_types 분류 확인
for rname, rinfo in ps.items():
if not isinstance(rinfo, dict):
continue
sub_types = rinfo.get("sub_types", [])
for st in sub_types:
checks.append((f"sub_type: {st.get('title','')[:20]}", st.get("sub_type", "미분류")))
rows = ""
for name, result in checks:
rows += f'<tr><td style="padding:6px 8px;">{name}</td><td style="padding:6px 8px;">{result}</td></tr>\n'
return rows
def _gen_stage_4(ctx, steps_dir):
"""Stage 4 결과: 측정값 + 품질 점수."""
measurement = ctx.measurement or {}
quality_score = ctx.quality_score if hasattr(ctx, 'quality_score') else "N/A"
slide_m = measurement.get("slide", {})
zones = measurement.get("zones", {})
zone_rows = ""
for zone_name, zone_data in zones.items():
overflowed = zone_data.get("overflowed", False)
excess = zone_data.get("excess_px", 0)
client_h = zone_data.get("clientHeight", 0)
scroll_h = zone_data.get("scrollHeight", 0)
icon = "" if overflowed else ""
bg = "#fee2e2" if overflowed else "#f0fdf4"
zone_rows += (f'<tr style="background:{bg};"><td style="padding:6px 8px;">{icon} {zone_name}</td>'
f'<td style="padding:6px 8px;">{client_h}px</td>'
f'<td style="padding:6px 8px;">{scroll_h}px</td>'
f'<td style="padding:6px 8px;">{excess:+d}px</td></tr>\n')
if isinstance(quality_score, (int, float)) and quality_score < 0:
score_color = "#d97706" # 미평가 = 주황
quality_score = "미평가 (비전 모델 미응답)"
elif isinstance(quality_score, (int, float)) and quality_score >= 80:
score_color = "#16a34a"
else:
score_color = "#dc2626"
# 블록/recipe 정보 (page_structure에서)
recipe_rows = ""
ps = ctx.page_structure.roles if hasattr(ctx.page_structure, 'roles') else {}
refs = ctx.references if hasattr(ctx, 'references') else {}
for rname, rinfo in ps.items():
if not isinstance(rinfo, dict):
continue
schema = rinfo.get("group_schema", "")
zone = rinfo.get("zone", "")
# block_id
role_refs = refs.get(rname, [])
block_id = ""
if role_refs:
r0 = role_refs[0]
block_id = r0.block_id if hasattr(r0, 'block_id') else r0.get("block_id", "")
is_recipe = block_id == "__needs_recipe__"
block_display = f"recipe ({schema})" if is_recipe else block_id
recipe_rows += (
f'<tr><td style="padding:6px 8px;">{zone}</td>'
f'<td style="padding:6px 8px;">{rname}</td>'
f'<td style="padding:6px 8px;">{schema}</td>'
f'<td style="padding:6px 8px;">{block_display}</td></tr>\n'
)
# popup 정보
popup_rows = ""
popups = ctx.normalized.popups if hasattr(ctx.normalized, 'popups') else []
for p in popups:
pid = p.popup_id if hasattr(p, 'popup_id') else ""
target = p.target_role if hasattr(p, 'target_role') else ""
pfile = p.popup_file if hasattr(p, 'popup_file') else ""
popup_rows += (
f'<tr><td style="padding:6px 8px;">{pid}</td>'
f'<td style="padding:6px 8px;">{target or "미연결"}</td>'
f'<td style="padding:6px 8px;">{pfile or "미확정"}</td></tr>\n'
)
html = f"""<!DOCTYPE html><html><head><meta charset="UTF-8">
<style>*{{margin:0;padding:0;box-sizing:border-box;}}body{{background:#e5e5e5;padding:10px;font-family:sans-serif;word-break:keep-all;}}
table{{border-collapse:collapse;font-size:12px;width:100%;max-width:700px;margin-top:8px;}}
th{{padding:8px;text-align:left;}}td{{padding:6px 8px;border-bottom:1px solid #ddd;}}</style>
</head><body>
<div style="font-size:16px;font-weight:bold;margin-bottom:8px;">Stage 4: 품질 게이트</div>
<div style="font-size:24px;font-weight:900;color:{score_color};margin-bottom:12px;">품질 점수: {quality_score}</div>
<div style="font-size:12px;color:#555;margin-bottom:4px;">슬라이드: clientHeight={slide_m.get("clientHeight", "?")}px, scrollHeight={slide_m.get("scrollHeight", "?")}px, overflow={slide_m.get("overflowed", "?")}</div>
<div style="font-weight:700;margin-top:16px;margin-bottom:4px;">Overflow 측정</div>
<table>
<tr style="background:#1e293b;color:white;"><th>영역</th><th>clientH</th><th>scrollH</th><th>excess</th></tr>{zone_rows}</table>
<div style="font-weight:700;margin-top:16px;margin-bottom:4px;">블록/Recipe 선택</div>
<table>
<tr style="background:#1e293b;color:white;"><th>zone</th><th>role</th><th>schema</th><th>block/recipe</th></tr>{recipe_rows}</table>
<div style="font-weight:700;margin-top:16px;margin-bottom:4px;">Popup 연결</div>
<table>
<tr style="background:#1e293b;color:white;"><th>popup_id</th><th>target_role</th><th>popup_file</th></tr>{popup_rows if popup_rows else '<tr><td colspan="3">없음</td></tr>'}</table>
<div style="font-weight:700;margin-top:16px;margin-bottom:4px;">구조 검증</div>
<table>
<tr style="background:#1e293b;color:white;"><th>검증 항목</th><th>결과</th></tr>
{_gen_structure_validation(ctx)}
</table>
</body></html>"""
(steps_dir / "stage_4.html").write_text(html, encoding="utf-8")