Phase W + V' 완료: before→filled→after 파이프라인 + 조립 로직 수정

Phase W:
- weight 비율 초기 배정 (space_allocator header 높이 반영)
- block_assembler 공통 조립 함수 (filled/assembled 통합)
- filled → Selenium 측정 → context 저장
- sidebar overflow 확장 + body 재배분
- sub_layouts 사전 계산 (이미지 누락 해결)

Phase V':
- 팝업 링크 우측상단 배치 (인라인 → position:absolute)
- 표 내용 Kei 판단 (공란 크기 계산 → 행/열 산출 → Kei 요약)
- 출처 라벨 삭제 + 이미지 아래 캡션 배치
- after 공란 제거 (결론 바로 위까지 body/sidebar 채움)

추가:
- V-10 bold 키워드: 기계적 추출 → Kei 문맥 판단
- ** 마크다운 → <strong> 변환
- [이미지:] 마커 제거 (bold 변환 전 처리)
- grid-template-rows AFTER 크기 반영 (Sonnet final)
- assemble_stage2 CSS font-size override, white-space fix
- 하드코딩 전수 검토 완료
- 본심 여러 topic 텍스트 합침

Phase X 계획 문서 작성 (동적 역할 구조)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-06 05:00:52 +09:00
parent 24eb1bc5ad
commit 1f7579cf64
64 changed files with 13955 additions and 696 deletions

562
scripts/assemble_stage2.py Normal file
View File

@@ -0,0 +1,562 @@
"""Stage 1.8 context 데이터만으로 stage_2 HTML을 코드로 조립.
사용하는 데이터 (해당 run의 stage_1_8_context.json에서):
- topics[].structured_text — Kei가 원본 85% 보존하여 구조화한 텍스트 (조립용)
- topics[].source_data — Kei가 정리한 메타 요약 (fallback)
- references[].design_reference_html — 블록 템플릿 렌더링 결과 (SVG 포함)
- sub_layouts — 서브 컨테이너 치수
- enhancement_result — V-7 종속꼭지, V-9 강조, V-10 bold
- containers — 컨테이너 크기 (재배분 후)
- font_hierarchy — 폰트 크기
- analysis.core_message — 핵심 메시지
- normalized.popups — 팝업 데이터 (비교표 등)
외부 데이터, 하드코딩 없음. 이 JSON 파일만으로 조립.
"""
import json
import re
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
def assemble(run_dir: str):
run = Path(run_dir)
ctx = json.loads((run / "stage_1_8_context.json").read_text(encoding="utf-8"))
topics = ctx["topics"]
topic_map = {t["id"]: t for t in topics}
ps = ctx["page_structure"]
if "roles" in ps:
ps = ps["roles"]
containers = ctx["containers"]
refs = ctx["references"]
sub_layouts = ctx.get("sub_layouts", {})
enh = ctx.get("enhancement_result", {})
fit = ctx.get("fit_result", {})
redist = fit.get("redistribution", {})
fh = ctx.get("font_hierarchy", {})
core_message = ctx.get("analysis", {}).get("core_message", "")
bold_kw = enh.get("bold_keywords", {}) if isinstance(enh.get("bold_keywords"), dict) else {}
emphasis_blocks = enh.get("emphasis_blocks", [])
subordinate_treatments = enh.get("subordinate_treatments", [])
popups = ctx.get("normalized", {}).get("popups", [])
title = ctx.get("analysis", {}).get("title", "")
ratio = ctx.get("container_ratio", [71, 29])
# ── 유틸 ──
def bold(text, role):
"""V-10 bold 키워드 적용."""
for kw in bold_kw.get(role, []):
if kw in text:
text = text.replace(kw, f"<strong>{kw}</strong>")
return text
def get_text(topic):
"""structured_text 우선, 없으면 source_data fallback."""
return topic.get("structured_text", "") or topic.get("source_data", "")
def get_emphasis(role):
"""V-9 강조 문장 가져오기."""
for e in emphasis_blocks:
if e.get("role") == role:
return e.get("sentence", "")
return ""
def extract_block_html(ref_html):
"""design_reference_html에서 주석 제거, CSS와 본문 분리."""
css_parts = re.findall(r'<style>(.*?)</style>', ref_html, re.DOTALL)
body = re.sub(r'<style>.*?</style>', '', ref_html, flags=re.DOTALL)
body = re.sub(r'<!--.*?-->', '', body, flags=re.DOTALL)
return "\n".join(css_parts), body.strip()
def popup_link(text, role):
"""[팝업: 제목] 마커를 클릭 가능한 링크로 변환."""
def _repl(m):
popup_title = m.group(1)
return f'<span style="color:#2563eb;font-size:{fh.get("sidebar",10)}px;cursor:pointer;">[{popup_title} 상세보기→]</span>'
return re.sub(r'\[팝업:\s*([^\]]+)\]', _repl, text)
def image_marker(text):
"""[이미지: 제목] 마커를 제거 (SVG로 별도 처리되므로)."""
return re.sub(r'\[이미지:\s*[^\]]+\]', '', text)
def structured_to_bullets(text, role, font_size, exclude_source=False):
"""structured_text → (불릿 HTML, [팝업 제목 리스트]).
[팝업:]은 텍스트에서 분리. [이미지:]는 제거. **bold** → <strong>. 출처: → 캡션."""
# [팝업:], [이미지:] 마커를 bold 변환 전에 먼저 처리
items = [] # [(indent, text)]
popup_titles = []
for raw_line in text.split("\n"):
stripped = raw_line.strip()
if not stripped:
continue
indent = 1 if raw_line.startswith(" ") else 0
# [팝업:] → 분리 (줄 어디에 있든 매칭, bold 변환 전)
popup_match = re.search(r'\[팝업:\s*([^\]]+)\]', stripped)
if popup_match:
popup_titles.append(popup_match.group(1))
continue
# [이미지:] → 제거 (bold 변환 전)
if re.search(r'\[이미지:', stripped):
continue
# 마크다운 bold → HTML (마커 처리 후)
stripped = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', stripped)
items.append((indent, stripped))
# items → HTML
html = ""
for indent, line in items:
line = bold(line, role)
clean = line.lstrip("")
if line.startswith("출처:") or clean.startswith("출처:"):
if exclude_source:
continue # 이미지 아래에 별도 배치됨
caption = re.sub(r'^출처:\s*', '', clean)
html += f'<div style="font-size:{font_size-2}px;color:#94a3b8;margin-top:3px;">{caption}</div>\n'
elif indent == 1:
html += f'<div class="bl bl-sub" style="padding-left:1em;"><span class="bl-m">•</span><span class="bl-t">{clean}</span></div>\n'
else:
html += f'<div class="bl"><span class="bl-m">•</span><span class="bl-t">{clean}</span></div>\n'
return html, popup_titles
def find_popup(title_keyword):
"""팝업 목록에서 제목 키워드로 매칭."""
for p in popups:
if title_keyword in p.get("title", ""):
return p
return None
def popup_to_compact_table(popup_content, font_size):
"""팝업의 마크다운 표를 compact HTML 테이블로 변환."""
# 마크다운 bold → HTML (팝업 정화가 안 된 run 대응)
popup_content = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', popup_content)
# JSX style 제거
popup_content = re.sub(r'<div\s+style=\{\{[^}]*\}\}\s*>', '', popup_content)
popup_content = popup_content.replace('</div>', '')
popup_content = re.sub(r'<br\s*/?>', '\n', popup_content)
lines = popup_content.split("\n")
table_lines = [l.strip() for l in lines if l.strip().startswith("|")]
if len(table_lines) < 3:
return ""
# 헤더
headers = [c.strip() for c in table_lines[0].split("|") if c.strip()]
# 구분선 스킵 (|---|---|)
rows = []
for tl in table_lines[2:]:
cells = [c.strip() for c in tl.split("|") if c.strip()]
if cells:
rows.append(cells)
if not headers or not rows:
return ""
# HTML 테이블 (compact)
col_count = len(headers)
header_html = "".join(f'<div style="padding:4px 8px;font-size:{font_size-1}px;font-weight:700;color:#fff;text-align:center;">{h}</div>' for h in headers)
rows_html = ""
for ri, row in enumerate(rows[:4]): # 최대 4행
bg = "#f8fafc" if ri % 2 == 0 else "#fff"
cells_html = ""
for ci, cell in enumerate(row):
align = "center" if ci == len(row) // 2 else ("left" if ci == 0 else "right")
weight = "600" if ci == 0 else "400"
color = "#1e40af" if ci == 0 else "#64748b"
cells_html += f'<div style="padding:4px 8px;font-size:{font_size-2}px;color:{color};font-weight:{weight};text-align:{align};">{bold(cell, "본심")}</div>'
rows_html += f'<div style="display:grid;grid-template-columns:repeat({col_count},1fr);border-top:1px solid #e2e8f0;background:{bg};align-items:center;">{cells_html}</div>\n'
return (
f'<div style="border:1px solid #e2e8f0;border-radius:6px;overflow:hidden;margin-top:6px;">'
f'<div style="display:grid;grid-template-columns:repeat({col_count},1fr);background:linear-gradient(135deg,#0d47a1,#1565c0);">{header_html}</div>'
f'{rows_html}</div>'
)
# ── 좌표 계산 (디자인 토큰에서 동적으로) ──
from src.fit_verifier import _load_design_tokens
tokens = _load_design_tokens()
slide_w = tokens.get("slide_width", 1280)
slide_h = tokens.get("slide_height", 720)
pad = tokens["spacing_page"]
header_h = tokens.get("header_height", 66)
gap_block = tokens["spacing_block"]
gap_small = tokens["spacing_small"]
inner_w = slide_w - pad * 2
body_w = int(inner_w * ratio[0] / 100)
sidebar_w = inner_w - body_w - gap_block
all_css = set()
role_htmls = {}
# ── 각 역할별 조립 ──
for role in ["배경", "본심", "첨부", "결론"]:
info = ps.get(role, {})
if not isinstance(info, dict):
continue
tids = info.get("topic_ids", [])
if not tids:
continue
ref_list = refs.get(role, [])
r0 = ref_list[0] if ref_list else {}
block_id = r0.get("block_id", "")
ref_html = r0.get("design_reference_html", "")
is_hier = r0.get("is_hierarchical", False)
sup_tids = r0.get("supporting_topic_ids", [])
primary_tid = r0.get("topic_id") or (tids[0] if tids else None)
ci = containers.get(role, {})
h = int(redist.get(role, ci.get("height_px", 0)))
w = ci.get("width_px", 0)
font_key = {"배경": "bg", "본심": "core", "첨부": "sidebar", "결론": "key_msg"}.get(role, "core")
font_size = fh.get(font_key, 12)
block_css, block_body = extract_block_html(ref_html)
if block_css:
# #7: CSS font-size override (font_hierarchy 기준으로 큰 폰트 축소)
def _override_font(m, fs=font_size):
val = float(m.group(1))
if val > fs + 2:
return f"font-size: {fs + 1}px"
elif val > fs:
return f"font-size: {fs}px"
return m.group(0)
block_css = re.sub(r'font-size:\s*(\d+(?:\.\d+)?)px', _override_font, block_css)
# gap, padding, number size도 font_size 비례
block_css = re.sub(r'gap:\s*\d+px', f'gap: {max(3, int(font_size * 0.4))}px', block_css)
block_css = re.sub(r'width:\s*32px;\s*\n\s*height:\s*32px',
f'width: {int(font_size * 2)}px;\n height: {int(font_size * 2)}px', block_css)
block_css = re.sub(r'padding:\s*12px\s+16px', f'padding: {int(font_size*0.7)}px {int(font_size)}px', block_css)
# #6: white-space: pre-line → normal (카드 불릿 간 빈줄 방지)
block_css = block_css.replace('white-space: pre-line', 'white-space: normal')
all_css.add(block_css)
layout = sub_layouts.get(role, {})
scs = layout.get("sub_containers", [])
primary_topic = topic_map.get(primary_tid, {})
topic_title = bold(primary_topic.get("title", ""), role)
# ════════════════════════════════════
# 결론
# ════════════════════════════════════
if role == "결론":
assembled = block_body
assembled = re.sub(r'>핵심 메시지 한 줄<', f'>{bold(core_message, role)}<', assembled)
assembled = re.sub(r'>부연 설명<', '><', assembled)
role_htmls[role] = assembled
# ════════════════════════════════════
# 첨부 — structured_text의 주불릿(•) = 카드 제목, 하위불릿( •) = 카드 설명
# ════════════════════════════════════
elif role == "첨부":
st = get_text(primary_topic)
# 마크다운 bold → HTML
st = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', st)
# 파싱: 주불릿(줄 시작 "• ")을 카드 구분자로
items = [] # [(title, [desc_lines])]
current_title = ""
current_descs = []
for line in st.split("\n"):
stripped = line.strip()
if not stripped:
continue
# 주불릿: 새 카드 시작
if stripped.startswith("") and not line.startswith(" "):
if current_title:
items.append((current_title, current_descs))
current_title = stripped[2:]
current_descs = []
# 하위불릿 또는 출처: 현재 카드의 설명
elif stripped.startswith("") and line.startswith(" "):
current_descs.append(stripped[2:])
elif stripped.startswith("출처:"):
current_descs.append(stripped)
else:
current_descs.append(stripped)
if current_title:
items.append((current_title, current_descs))
if not items:
items = [(primary_topic.get("title", ""), [st])]
cards = ""
for i, (card_title, desc_lines) in enumerate(items):
desc_html = ""
for dl in desc_lines:
dl = dl.strip()
if not dl:
continue
dl = bold(dl, role)
if dl.startswith("출처:"):
caption = re.sub(r'^출처:\s*', '', dl)
desc_html += f'<div style="font-size:{font_size-2}px;color:#94a3b8;margin-top:3px;">{caption}</div>\n'
else:
desc_html += f'<div class="bl"><span class="bl-m">•</span><span class="bl-t">{dl}</span></div>\n'
cards += (
f'<div style="display:flex;gap:{max(4, int(font_size * 0.8))}px;align-items:flex-start;padding:{int(font_size*0.7)}px {int(font_size)}px;'
f'background:#f8fafc;border-radius:8px;border:1px solid #e2e8f0;">'
f'<div style="width:{int(font_size*2)}px;height:{int(font_size*2)}px;border-radius:50%;background:#2563eb;'
f'display:flex;align-items:center;justify-content:center;color:#fff;'
f'font-size:{font_size-1}px;font-weight:800;flex-shrink:0;">{i+1}</div>'
f'<div>'
f'<div style="font-size:{font_size}px;font-weight:700;color:#1e293b;margin-bottom:2px;">'
f'{bold(card_title.strip(), role)}</div>'
f'<div style="font-size:{font_size-1}px;color:#475569;line-height:1.4;">'
f'{desc_html}</div></div></div>\n'
)
# 첨부 컨테이너 높이에 맞게 gap/padding 동적 조절
sb_container_h = int(redist.get(role, ci.get("height_px", 0)))
n_cards = len(items)
sb_pad = min(10, max(4, sb_container_h // 50))
sb_gap = min(7, max(3, (sb_container_h - sb_pad * 2) // (n_cards * 10)))
role_htmls[role] = (
f'<div style="padding:{sb_pad}px;display:flex;flex-direction:column;gap:{sb_gap}px;'
f'height:100%;box-sizing:border-box;">'
f'<div style="font-size:{font_size-1}px;color:#64748b;font-weight:700;margin-bottom:2px;">'
f'{topic_title}</div>{cards}</div>'
)
# ════════════════════════════════════
# 배경 — callout 구조 + 종속꼭지 인라인 + 강조
# ════════════════════════════════════
elif role == "배경":
sub_html = ""
if is_hier and sup_tids:
for st_id in sup_tids:
st_topic = topic_map.get(st_id, {})
st_text = get_text(st_topic)
st_text = popup_link(st_text, role)
sub_html += (
f'<div style="padding-left:1em;margin-top:2px;color:#9b1c1c;font-size:{font_size-1}px;'
f'border-left:2px solid #fca5a5;">'
f'{bold(st_text[:120], role)}</div>'
)
emph = get_emphasis(role)
emph_html = ""
if emph:
emph_html = (
f'<div style="background:#991b1b;color:#fff;border-radius:3px;'
f'padding:3px 8px;font-size:{font_size-1}px;font-weight:700;margin-top:2px;">'
f'{emph}</div>'
)
bullets_html, bg_popups = structured_to_bullets(get_text(primary_topic), role, font_size)
# V'-1: 팝업 링크 우측상단
bg_popup_html = ""
if bg_popups:
links = " ".join(f'<span style="color:#2563eb;font-size:{font_size-2}px;cursor:pointer;">[{t}→]</span>' for t in bg_popups)
bg_popup_html = f'<div style="position:absolute;top:4px;right:8px;text-align:right;z-index:1;">{links}</div>'
# padding을 컨테이너 높이에 맞게 동적 조절
container_h = int(redist.get(role, ci.get("height_px", 0)))
pad_v = min(10, max(4, container_h // 20)) # 컨테이너 높이의 5% 정도
pad_h = min(14, max(6, container_h // 10))
role_htmls[role] = (
f'<div style="position:relative;background:linear-gradient(135deg,#fef2f2 0%,#fee2e2 100%);'
f'border:2px solid #fca5a5;border-radius:6px;padding:{pad_v}px {pad_h}px;'
f'display:flex;gap:{max(3, int(font_size * 0.4))}px;align-items:flex-start;width:100%;height:100%;box-sizing:border-box;">'
f'{bg_popup_html}'
f'<div style="font-size:1.1rem;flex-shrink:0;">⚠️</div>'
f'<div style="flex:1;font-size:{font_size}px;line-height:1.4;color:#7f1d1d;">'
f'<div style="font-size:{font_size+1}px;font-weight:800;color:#991b1b;margin-bottom:2px;">'
f'{topic_title}</div>'
f'{bullets_html}{sub_html}{emph_html}'
f'</div></div>'
)
# ════════════════════════════════════
# 본심 — SVG(좌) + 텍스트(우상) + 비교표(우하) + key-msg(하단)
# ════════════════════════════════════
elif role == "본심":
svg_sc = next((sc for sc in scs if sc["name"] == "svg"), None)
text_sc = next((sc for sc in scs if sc["name"] == "text_and_table"), None)
keymsg_sc = next((sc for sc in scs if sc["name"] == "keymsg"), None)
# 이미지: slide_images에서 실제 이미지 사용, 없으면 빈 placeholder
slide_images = ctx.get("slide_images", [])
img_html = ""
for img in slide_images:
b64 = img.get("b64", "")
if b64:
img_html = f'<img src="data:image/png;base64,{b64}" style="width:100%;height:100%;object-fit:contain;" />'
break
svg_w = int(svg_sc["width_px"]) if svg_sc else 200
svg_h = int(svg_sc["height_px"]) if svg_sc else 265
# 본심의 모든 topic 텍스트를 합침
all_core_text = "\n".join(get_text(topic_map.get(tid, {})) for tid in tids if topic_map.get(tid))
# 출처 텍스트를 이미지 아래에 배치
img_caption = ""
for line in all_core_text.split("\n"):
stripped = line.strip().lstrip("")
if stripped.startswith("출처:"):
img_caption = re.sub(r'^출처:\s*', '', stripped)
break
caption_html = f'<div style="font-size:{font_size-2}px;color:#94a3b8;text-align:center;margin-top:2px;">{img_caption}</div>' if img_caption else ""
svg_wrapped = (
f'<div style="width:{svg_w}px;flex-shrink:0;">'
f'<div style="height:{svg_h}px;border-radius:6px;overflow:hidden;">{img_html}</div>'
f'{caption_html}</div>'
)
# 텍스트 불릿 (출처는 이미지 아래에 별도 배치했으므로 제외)
bullets, core_popups = structured_to_bullets(all_core_text, role, font_size, exclude_source=True)
# V'-2: 팝업 원본을 Kei가 요약한 결과를 사용 (없으면 기존 compact 변환 fallback)
popup_summaries = enh.get("popup_summaries", {})
table_html = ""
used_popups = []
for pr in core_popups:
summary = popup_summaries.get(pr)
if summary:
used_popups.append(pr)
fmt = summary.get("format", "text")
popup_link_html = f'<div style="text-align:right;margin-bottom:2px;"><span style="color:#2563eb;font-size:{font_size-2}px;cursor:pointer;">[{pr}→]</span></div>'
if fmt == "table":
cols = summary.get("columns", [])
data = summary.get("data", [])
col_count = len(cols)
if col_count > 0 and data:
header_cells = "".join(
f'<div style="padding:{int(font_size*0.3)}px {int(font_size*0.6)}px;font-size:{font_size-1}px;font-weight:700;color:#fff;text-align:center;">{c}</div>'
for c in cols
)
rows_html = ""
for ri, row in enumerate(data):
bg = "#f8fafc" if ri % 2 == 0 else "#fff"
cells = ""
for ci, cell in enumerate(row):
c_color = "#1e40af" if ci == 0 else "#64748b"
c_weight = "600" if ci == 0 else "400"
cells += f'<div style="padding:{int(font_size*0.3)}px {int(font_size*0.6)}px;font-size:{font_size-2}px;color:{c_color};font-weight:{c_weight};">{bold(cell, role)}</div>'
rows_html += f'<div style="display:grid;grid-template-columns:repeat({col_count},1fr);border-top:1px solid #e2e8f0;background:{bg};align-items:center;">{cells}</div>\n'
compact = (
f'<div style="border:1px solid #e2e8f0;border-radius:{int(font_size*0.5)}px;overflow:hidden;">'
f'<div style="display:grid;grid-template-columns:repeat({col_count},1fr);background:linear-gradient(135deg,#0d47a1,#1565c0);">{header_cells}</div>'
f'{rows_html}</div>'
)
table_html += f'<div style="margin-top:{int(font_size*0.5)}px;">{popup_link_html}{compact}</div>'
elif fmt == "bullets":
items = summary.get("items", [])
bullets_html = "".join(
f'<div class="bl"><span class="bl-m">•</span><span class="bl-t">{bold(item, role)}</span></div>'
for item in items
)
table_html += f'<div style="margin-top:{int(font_size*0.5)}px;font-size:{font_size-1}px;">{popup_link_html}{bullets_html}</div>'
elif fmt == "text":
text = summary.get("summary", "")
table_html += f'<div style="margin-top:{int(font_size*0.5)}px;font-size:{font_size-1}px;color:#475569;">{popup_link_html}{bold(text, role)}</div>'
else:
# fallback: 기존 compact 변환
popup = find_popup(pr)
if popup:
content = popup.get("content", "")
if content.count("|") > 3:
compact = popup_to_compact_table(content, font_size)
if compact:
popup_link_html = f'<div style="text-align:right;margin-bottom:2px;"><span style="color:#2563eb;font-size:{font_size-2}px;cursor:pointer;">[{pr}→]</span></div>'
table_html += f'<div style="margin-top:{int(font_size*0.5)}px;">{popup_link_html}{compact}</div>'
used_popups.append(pr)
# 표/요약에 연결되지 않은 팝업은 컨테이너 우측상단에
remaining_popups = [p for p in core_popups if p not in used_popups]
core_popup_html = ""
if remaining_popups:
links = " ".join(f'<span style="color:#2563eb;font-size:{font_size-2}px;cursor:pointer;">[{t}→]</span>' for t in remaining_popups)
core_popup_html = f'<div style="text-align:right;margin-bottom:2px;">{links}</div>'
text_wrapped = (
f'<div style="flex:1;display:flex;flex-direction:column;min-height:0;">'
f'{core_popup_html}'
f'<div style="font-size:{font_size}px;line-height:1.55;color:#333;flex:1;">{bullets}</div>'
f'</div>'
)
# key-msg
keymsg_html = ""
if keymsg_sc and core_message:
keymsg_html = (
f'<div style="background:#dbeafe;border:1px solid #2563eb;border-radius:4px;'
f'padding:{int(font_size*0.4)}px {int(font_size*0.8)}px;font-size:{font_size+1}px;font-weight:700;color:#1e40af;text-align:center;'
f'margin-top:{gap_small}px;flex-shrink:0;">{bold(core_message, role)}</div>'
)
# 레이아웃: 이미지(좌)+불릿(우) 위, 표(전체 폭) 아래, keymsg 최하단
role_htmls[role] = (
f'<div style="width:100%;height:100%;background:white;padding:{gap_small}px;box-sizing:border-box;'
f'display:flex;flex-direction:column;">'
f'<div style="font-weight:700;font-size:{font_size+1}px;color:#1a365d;margin-bottom:4px;">'
f'{topic_title}</div>'
f'<div style="display:flex;gap:{max(6, int(font_size * 0.8))}px;min-height:0;align-items:flex-start;">'
f'{svg_wrapped}{text_wrapped}</div>'
f'{table_html}'
f'{keymsg_html}</div>'
)
# ── 슬라이드 좌표 ──
bg_h = int(redist.get("배경", containers.get("배경", {}).get("height_px", 0)))
core_h = int(redist.get("본심", containers.get("본심", {}).get("height_px", 0)))
sb_h = int(redist.get("첨부", containers.get("첨부", {}).get("height_px", 0)))
concl_h = int(redist.get("결론", containers.get("결론", {}).get("height_px", 0)))
bg_top = pad + header_h + gap_block
core_top = bg_top + bg_h + gap_small
sb_top = bg_top
# #9: 결론 바로 위까지 body/sidebar 모두 채움 — 공란 제거
ft_top = slide_h - pad - concl_h - gap_block # 결론 위치: 슬라이드 바닥 - pad - 결론높이 - gap
column_bottom = ft_top - gap_block # body/sidebar 바닥: 결론 위 gap만큼 위
core_h = column_bottom - core_top # 본심: 배경 아래~column 바닥
sb_h = column_bottom - sb_top # 첨부: column 바닥까지
css_block = "\n".join(all_css)
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;}}
.bl{{display:flex;gap:0;margin-bottom:2px;}}.bl-m{{flex-shrink:0;width:1em;text-align:left;}}.bl-t{{flex:1;word-break:keep-all;}}
.bl-sub{{padding-left:1em;}}
{css_block}
</style></head><body>
<div style="font-size:14px;font-weight:bold;margin-bottom:4px;">Stage 2: 코드 조립 결과 (context 데이터만, Sonnet 없음)</div>
<div style="font-size:11px;color:#666;margin-bottom:10px;">sub_layouts + design_reference_html + structured_text + V-7~V-10 + popups</div>
<div style="width:{slide_w}px;height:{slide_h}px;background:white;position:relative;border:1px solid #ccc;">
<div style="position:absolute;left:{pad}px;top:{pad}px;width:{inner_w}px;height:{header_h}px;background:#f8fafc;border-bottom:3px solid #2563eb;display:flex;align-items:center;padding:0 20px;font-size:22px;font-weight:900;color:#1e293b;">{title}</div>
<div style="position:absolute;left:{pad}px;top:{bg_top}px;width:{body_w}px;height:{bg_h}px;border-radius:6px;overflow:hidden;">
{role_htmls.get("배경", "")}
</div>
<div style="position:absolute;left:{pad}px;top:{core_top}px;width:{body_w}px;height:{core_h}px;border-radius:6px;overflow:hidden;">
{role_htmls.get("본심", "")}
</div>
<div style="position:absolute;left:{pad + body_w + gap_block}px;top:{sb_top}px;width:{sidebar_w}px;height:{sb_h}px;border-radius:6px;overflow:hidden;">
{role_htmls.get("첨부", "")}
</div>
<div style="position:absolute;left:{pad}px;top:{ft_top}px;width:{inner_w}px;height:{concl_h}px;border-radius:8px;overflow:hidden;">
{role_htmls.get("결론", "")}
</div>
</div></body></html>"""
out = run / "steps" / "stage_2_code_assembled.html"
out.write_text(html, encoding="utf-8")
print(f"저장: {out} ({len(html)} bytes)")
if __name__ == "__main__":
run_dir = sys.argv[1] if len(sys.argv) > 1 else "data/runs/20260403_120051"
assemble(run_dir)

167
scripts/gen_viz_layers.py Normal file
View File

@@ -0,0 +1,167 @@
"""Step 1~4를 같은 슬라이드 레이아웃 위에 레이어로 쌓아 PNG 생성."""
import json, urllib.parse, time, sys
from pathlib import Path
sys.path.insert(0, ".")
run_dir = Path("data/runs/20260402_091318")
ctx_1a = json.loads((run_dir / "stage_1a_context.json").read_text(encoding="utf-8"))
ctx_1b = json.loads((run_dir / "stage_1b_context.json").read_text(encoding="utf-8"))
ctx_15a = json.loads((run_dir / "stage_1_5a_context.json").read_text(encoding="utf-8"))
ctx_17 = json.loads((run_dir / "stage_1_7_context.json").read_text(encoding="utf-8"))
ctx_15b = json.loads((run_dir / "stage_1_5b_context.json").read_text(encoding="utf-8"))
topics = ctx_1b.get("topics", [])
containers = ctx_15a.get("containers", {})
fh = ctx_15a.get("font_hierarchy", {})
ratio = ctx_15a.get("container_ratio", [72, 28])
refs = ctx_17.get("references", {})
ps = ctx_1a.get("page_structure", {})
if "roles" in ps:
ps = ps["roles"]
containers_b = ctx_15b.get("containers", {})
topic_map = {t["id"]: t for t in topics}
slide_w, slide_h = 1280, 720
pad = 40
header_h = 66
gap = 20
footer_h = containers.get("결론", {}).get("height_px", 60)
inner_w = slide_w - pad * 2
body_pct = ratio[0] if ratio else 72
sidebar_pct = ratio[1] if len(ratio) > 1 else 28
body_w = int(inner_w * body_pct / 100)
sidebar_w = inner_w - body_w - gap
body_zone_h = slide_h - pad * 2 - header_h - footer_h - gap * 2
bg_h = containers.get("배경", {}).get("height_px", 117)
core_h = body_zone_h - bg_h - 12
L = {
"배경": {"x": pad, "y": pad+header_h+gap, "w": body_w, "h": bg_h},
"본심": {"x": pad, "y": pad+header_h+gap+bg_h+12, "w": body_w, "h": core_h},
"첨부": {"x": pad+body_w+gap, "y": pad+header_h+gap, "w": sidebar_w, "h": body_zone_h},
"결론": {"x": pad, "y": slide_h-pad-footer_h, "w": inner_w, "h": footer_h},
}
C = {"배경": "#dc2626", "본심": "#2563eb", "첨부": "#16a34a", "결론": "#7c3aed"}
def area(role, inner):
p = L[role]; c = C[role]
return (f'<div style="position:absolute;left:{p["x"]}px;top:{p["y"]}px;'
f'width:{p["w"]}px;height:{p["h"]}px;border:2px solid {c};'
f'border-radius:6px;overflow:hidden;background:{c}08;'
f'padding:6px;font-size:9px;line-height:1.4;">{inner}</div>')
def header(title):
return (f'<div style="position:absolute;left:{pad}px;top:{pad}px;width:{inner_w}px;'
f'height:{header_h}px;background:#f8fafc;border-bottom:3px solid #2563eb;'
f'display:flex;align-items:center;padding:0 20px;font-size:22px;'
f'font-weight:900;color:#1e293b;">건설산업 DX의 올바른 이해</div>')
def slide(step_title, areas_html):
return (f'<!DOCTYPE html><html><head><meta charset="UTF-8">'
f'<style>*{{margin:0;padding:0;box-sizing:border-box;}}'
f'body{{background:#e5e5e5;padding:10px;font-family:sans-serif;}}</style></head><body>'
f'<div style="font-size:14px;font-weight:bold;margin-bottom:6px;">{step_title}</div>'
f'<div style="width:{slide_w}px;height:{slide_h}px;background:white;'
f'position:relative;border:1px solid #ccc;">'
f'{header(step_title)}{areas_html}</div></body></html>')
# Step 1
a1 = ""
for role in L:
c = C[role]; p = L[role]
fk = {"배경":"bg","본심":"core","첨부":"sidebar","결론":"key_msg"}.get(role,"core")
fv = fh.get(fk, 12)
a1 += area(role,
f'<div style="text-align:center;margin-top:{p["h"]//2-15}px;">'
f'<b style="color:{c};font-size:13px;">{role}</b><br>'
f'<span style="color:#888;font-size:10px;">{p["w"]}x{p["h"]}px / font:{fv}px</span></div>')
# Step 2
a2 = ""
for role in L:
c = C[role]; info = ps.get(role, {}); tids = info.get("topic_ids", [])
w = info.get("weight", 0)
inner = f'<div style="font-size:8px;color:{c};font-weight:bold;">{role} (w:{w})</div>'
for tid in tids:
t = topic_map.get(tid, {})
inner += (f'<div style="background:white;border:1px solid #ddd;border-radius:3px;'
f'padding:3px;margin:2px 0;">'
f'<b style="font-size:8px;">T{tid}: {t.get("title","")[:25]}</b><br>'
f'<span style="font-size:7px;color:#888;">'
f'{t.get("purpose","")} / {t.get("relation_type","")}</span></div>')
a2 += area(role, inner)
# Step 3
a3 = ""
for role in L:
c = C[role]; ref = refs.get(role, {}); p = L[role]
bid = ref.get("block_id", "?"); vtype = ref.get("visual_type", "?")
info = ps.get(role, {}); tids = info.get("topic_ids", [])
tnames = ", ".join(f"T{tid}" for tid in tids)
mt = max(0, p["h"]//2-25)
a3 += area(role,
f'<div style="text-align:center;margin-top:{mt}px;">'
f'<div style="font-size:9px;color:{c};">{role} ({tnames})</div>'
f'<div style="font-size:14px;margin:2px 0;">📦</div>'
f'<div style="font-size:11px;font-weight:bold;">{bid}</div>'
f'<div style="font-size:8px;color:#888;">type: {vtype}</div></div>')
# Step 4
a4 = ""
for role in L:
c = C[role]; ref = refs.get(role, {}); p = L[role]
bid = ref.get("block_id", "?")
cb = containers_b.get(role, {}); db = cb.get("design_budget") or {}
text_h = db.get("text_height_px", 0)
avail_h = db.get("available_height_px", 0)
fits = db.get("fits", False)
total = max(text_h + avail_h, 1)
tp = int(text_h / total * 100)
bw = p["w"] - 20
fc = "green" if fits else "red"
a4 += area(role,
f'<div style="padding:2px;">'
f'<div style="font-size:9px;color:{c};font-weight:bold;">{role}: {bid}</div>'
f'<div style="display:flex;height:14px;border-radius:3px;overflow:hidden;margin:4px 0;width:{bw}px;">'
f'<div style="width:{tp}%;background:#ff6b6b;font-size:7px;color:white;text-align:center;line-height:14px;">텍스트{text_h}px</div>'
f'<div style="width:{100-tp}%;background:#51cf66;font-size:7px;color:white;text-align:center;line-height:14px;">여유{avail_h}px</div>'
f'</div>'
f'<div style="font-size:8px;color:{fc};font-weight:bold;">fits:{fits} / {p["w"]}x{p["h"]}px</div></div>')
htmls = {
"viz_1_containers": slide(f"Step 1: 컨테이너 포션과 위치 (비율 {body_pct}:{sidebar_pct})", a1),
"viz_2_content": slide("Step 2: 각 영역별 내용 배치", a2),
"viz_3_blocks": slide("Step 3: 블록 선택 결과", a3),
"viz_4_budget": slide("Step 4: 블록별 디자인 예산", a4),
}
for name, html in htmls.items():
(run_dir / f"{name}.html").write_text(html, encoding="utf-8")
# PNG
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
opts = Options()
opts.add_argument("--headless")
opts.add_argument("--no-sandbox")
opts.add_argument("--force-device-scale-factor=2")
driver = webdriver.Chrome(options=opts)
driver.set_window_size(1380, 820)
for name in htmls:
html = (run_dir / f"{name}.html").read_text(encoding="utf-8")
encoded = urllib.parse.quote(html, safe="")
driver.get(f"data:text/html;charset=utf-8,{encoded}")
time.sleep(2)
driver.save_screenshot(str(run_dir / f"{name}.png"))
print(f"{name}.png")
driver.quit()
print("완료")

View File

@@ -0,0 +1,314 @@
"""Stage별 실제 출력 데이터로 step HTML 생성.
각 step은 이전 step 위에 레이어를 쌓아가는 구조:
- Step 0: Kei 꼭지 (테이블)
- Step 1: 빈 컨테이너 (1280x720 슬라이드)
- Step 2: Step 1 + 블록 선택 (컨테이너 안에 블록 표시)
- Step 3: Step 2 + 재배분 반영 (크기 변경 + 보강)
- Step 4: 최종 결과물 (final.html)
"""
import json
import sys
from pathlib import Path
def _load(run: Path, name: str) -> dict:
return json.loads((run / name).read_text(encoding="utf-8"))
def _colors():
return {"배경": "#dc2626", "본심": "#2563eb", "첨부": "#16a34a", "결론": "#7c3aed"}
def _calc_coords(containers, ratio, pad=40, gap=20, header_h=66):
"""컨테이너 좌표 계산. containers dict에서 실제 px 값 사용."""
inner_w = 1280 - pad * 2
body_w = int(inner_w * ratio[0] / 100)
sidebar_w = inner_w - body_w - gap
sidebar_left = pad + body_w + gap
def get(c, key):
return c.get(key, 0) if isinstance(c, dict) else getattr(c, key, 0)
bg_px = get(containers.get("배경", {}), "height_px")
core_px = get(containers.get("본심", {}), "height_px")
sidebar_px = get(containers.get("첨부", {}), "height_px")
footer_px = get(containers.get("결론", {}), "height_px")
bg_top = pad + header_h + gap
core_top = bg_top + bg_px + 8
footer_top = max(core_top + core_px, bg_top + sidebar_px) + gap
return {
"header": {"left": pad, "top": pad, "width": inner_w, "height": header_h},
"배경": {"left": pad, "top": bg_top, "width": body_w, "height": bg_px},
"본심": {"left": pad, "top": core_top, "width": body_w, "height": core_px},
"첨부": {"left": sidebar_left, "top": bg_top, "width": sidebar_w, "height": sidebar_px},
"결론": {"left": pad, "top": footer_top, "width": inner_w, "height": footer_px},
}
def _box_html(coord, role, label, colors, extra_style=""):
c = colors.get(role, "#333")
return (
f'<div style="position:absolute;left:{coord["left"]}px;top:{coord["top"]}px;'
f'width:{coord["width"]}px;height:{coord["height"]}px;'
f'border:2px solid {c};border-radius:6px;background:{c}08;'
f'{extra_style}">'
f'{label}</div>\n'
)
def _header_html(coord, title):
return (
f'<div style="position:absolute;left:{coord["left"]}px;top:{coord["top"]}px;'
f'width:{coord["width"]}px;height:{coord["height"]}px;'
f'background:#f8fafc;border-bottom:3px solid #2563eb;display:flex;'
f'align-items:center;padding:0 20px;font-size:22px;font-weight:900;color:#1e293b;">'
f'{title}</div>\n'
)
def _slide_wrap(title, subtitle, body):
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;">
{body}
</div></body></html>"""
def gen_step0(run: Path, out: Path):
ctx1b = _load(run, "stage_1b_context.json")
topics = ctx1b.get("topics", [])
ps = ctx1b.get("page_structure", {}).get("roles", {})
role_map = {}
for role, info in ps.items():
for tid in info.get("topic_ids", []):
role_map[tid] = role
colors = _colors()
rows = ""
for t in topics:
tid = t.get("id")
role = role_map.get(tid, "?")
c = colors.get(role, "#333")
bg = "#f8fafc" if tid % 2 == 0 else "#fff"
rows += (f'<tr style="background:{bg};"><td style="padding:6px 8px;text-align:center;">{tid}</td>'
f'<td style="padding:6px 8px;font-weight:700;">{t.get("title","")}</td>'
f'<td style="padding:6px 8px;">{t.get("purpose","")}</td>'
f'<td style="padding:6px 8px;">{t.get("layer","")}</td>'
f'<td style="padding:6px 8px;">{t.get("relation_type","")}</td>'
f'<td style="padding:6px 8px;color:{c};font-weight:700;">{role}</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;">Step 0: Kei 꼭지 추출 (Stage 1A/1B)</div>
<div style="font-size:11px;color:#666;margin-bottom:12px;">run: {run.name}</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></body></html>"""
(out / "step0_kei_topics.html").write_text(html, encoding="utf-8")
print("step0 생성")
def gen_step1(run: Path, out: Path):
"""Step 1: 빈 컨테이너."""
ctx15a = _load(run, "stage_1_5a_context.json")
containers = ctx15a.get("containers", {})
ratio = ctx15a.get("container_ratio", [65, 35])
fh = ctx15a.get("font_hierarchy", {})
colors = _colors()
coords = _calc_coords(containers, ratio)
body = _header_html(coords["header"], "건설산업 DX의 올바른 이해")
for role in ["배경", "본심", "첨부", "결론"]:
coord = coords[role]
c = colors[role]
font_key = {"배경": "bg", "본심": "core", "첨부": "sidebar", "결론": "key_msg"}.get(role)
label = (f'<div style="text-align:center;margin-top:{coord["height"]//2 - 15}px;">'
f'<b style="color:{c};font-size:13px;">{role}</b><br>'
f'<span style="color:#888;font-size:10px;">{coord["width"]}x{coord["height"]}px / font:{fh.get(font_key,"?")}px</span></div>')
body += _box_html(coord, role, label, colors)
html = _slide_wrap(
"Step 1: 빈 컨테이너 (Stage 1.5a)",
f'비율 {ratio[0]}:{ratio[1]}',
body,
)
(out / "step1_containers.html").write_text(html, encoding="utf-8")
print("step1 생성")
return coords, containers, ratio, fh
def gen_step2(run: Path, out: Path, coords, fh):
"""Step 2: Step 1 컨테이너 위에 블록 선택 표시."""
ctx17 = _load(run, "stage_1_7_context.json")
refs = ctx17.get("references", {})
colors = _colors()
ctx15a = _load(run, "stage_1_5a_context.json")
ratio = ctx15a.get("container_ratio", [65, 35])
body = _header_html(coords["header"], "건설산업 DX의 올바른 이해")
for role in ["배경", "본심", "첨부", "결론"]:
coord = coords[role]
c = colors[role]
ref_list = refs.get(role, [])
if not isinstance(ref_list, list):
ref_list = [ref_list]
# 블록 정보를 컨테이너 안에 표시
block_lines = []
for r in ref_list:
if isinstance(r, dict):
bid = r.get("block_id", "?")
var = r.get("variant", "default")
tid = r.get("topic_id", "?")
sup = r.get("supporting_topic_ids", [])
hier = r.get("is_hierarchical", False)
line = f'꼭지{tid}: <b>{bid}</b> ({var})'
if hier:
line += f' <span style="color:#dc2626;font-size:9px;">★주종</span>'
if sup:
line += f' <span style="font-size:9px;color:#888;">[종속:{sup}]</span>'
block_lines.append(line)
block_html = '<br>'.join(block_lines)
font_key = {"배경": "bg", "본심": "core", "첨부": "sidebar", "결론": "key_msg"}.get(role)
label = (f'<div style="padding:6px 10px;">'
f'<div style="font-size:10px;color:{c};font-weight:700;margin-bottom:4px;">'
f'{role} ({coord["width"]}x{coord["height"]}px)</div>'
f'<div style="font-size:11px;line-height:1.6;">{block_html}</div>'
f'</div>')
body += _box_html(coord, role, label, colors)
html = _slide_wrap(
"Step 2: 블록 선택 (Stage 1.7) — Step 1 컨테이너 위에 블록 표시",
"layer 기반 주종 판단. 배경: 꼭지1(intro)+꼭지2(supporting) → 주종합침 블록 1개",
body,
)
(out / "step2_blocks.html").write_text(html, encoding="utf-8")
print("step2 생성")
def gen_step3(run: Path, out: Path, containers, ratio, fh):
"""Step 3: Step 2 위에 재배분 반영."""
ctx18 = _load(run, "stage_1_8_context.json")
fit = ctx18.get("fit_result", {})
enh = ctx18.get("enhancement_result", {})
redist = fit.get("redistribution", {})
# 재배분된 containers
new_containers = {}
for role, c in containers.items():
h = c.get("height_px", 0) if isinstance(c, dict) else getattr(c, "height_px", 0)
new_h = int(redist.get(role, h))
if isinstance(c, dict):
new_containers[role] = {**c, "height_px": new_h}
else:
new_containers[role] = {"height_px": new_h, "width_px": getattr(c, "width_px", 0), "zone": getattr(c, "zone", "")}
colors = _colors()
new_coords = _calc_coords(new_containers, ratio)
# 블록 선택 정보도 가져옴
ctx17 = _load(run, "stage_1_7_context.json")
refs = ctx17.get("references", {})
body = _header_html(new_coords["header"], "건설산업 DX의 올바른 이해")
for role in ["배경", "본심", "첨부", "결론"]:
coord = new_coords[role]
c = colors[role]
# fit 상태
rf = fit.get("roles", {}).get(role, {})
status = rf.get("fit_status", "?")
icon = {"OK": "", "TIGHT": "⚠️", "OVERFLOW": ""}.get(status, "?")
needed = rf.get("total_required_px", 0)
old_h = rf.get("allocated_px", 0)
new_h = int(redist.get(role, old_h))
delta = new_h - old_h
# 블록 정보
ref_list = refs.get(role, [])
if not isinstance(ref_list, list):
ref_list = [ref_list]
block_lines = []
for r in ref_list:
if isinstance(r, dict):
bid = r.get("block_id", "?")
tid = r.get("topic_id", "?")
sup = r.get("supporting_topic_ids", [])
hier = r.get("is_hierarchical", False)
line = f'꼭지{tid}: <b>{bid}</b>'
if hier:
line += f' ★주종 [종속:{sup}]'
block_lines.append(line)
# 보강 정보
emps = [e for e in enh.get("emphasis_blocks", []) if e.get("role") == role]
bolds = enh.get("bold_keywords", {}).get(role, [])
delta_str = f" ({delta:+d}px)" if abs(delta) > 0 else ""
enh_lines = []
if emps:
enh_lines.append(f'<span style="font-size:9px;color:#991b1b;">강조: "{emps[0].get("sentence","")[:30]}..."</span>')
if bolds:
enh_lines.append(f'<span style="font-size:9px;color:#2563eb;">bold: {bolds[:4]}</span>')
label = (f'<div style="padding:4px 8px;">'
f'<div style="font-size:10px;color:{c};font-weight:700;">'
f'{icon} {role} {coord["width"]}x{new_h}px{delta_str}</div>'
f'<div style="font-size:9px;color:#888;">필요 {needed:.0f}px</div>'
f'<div style="font-size:10px;line-height:1.5;margin-top:2px;">{"<br>".join(block_lines)}</div>'
f'<div style="margin-top:2px;">{"<br>".join(enh_lines)}</div>'
f'</div>')
body += _box_html(coord, role, label, colors)
html = _slide_wrap(
"Step 3: 적합성 검증 + 재배분 + 보강 (Stage 1.8)",
f"재배분: {', '.join(f'{r}:{int(redist.get(r,0))}px' for r in redist)}",
body,
)
(out / "step3_fit_result.html").write_text(html, encoding="utf-8")
print("step3 생성")
def gen_step4(run: Path, out: Path):
"""Step 4: final.html 링크."""
html = """<!DOCTYPE html><html><head><meta charset="UTF-8">
<style>body{font-family:sans-serif;padding:20px;}</style></head><body>
<h2>Step 4: 최종 결과물 (Sonnet HTML 생성)</h2>
<p><a href="../final.html" style="font-size:18px;">final.html 열기 →</a></p>
<p style="margin-top:12px;"><a href="../첨부1_혼용 대표 사례.html">첨부1</a> · <a href="../첨부2_DX와 BIM의 구분.html">첨부2</a></p>
</body></html>"""
(out / "step4_final.html").write_text(html, encoding="utf-8")
print("step4 생성")
def main(run_dir: str):
run = Path(run_dir)
out = run / "steps"
out.mkdir(exist_ok=True)
gen_step0(run, out)
coords, containers, ratio, fh = gen_step1(run, out)
gen_step2(run, out, coords, fh)
gen_step3(run, out, containers, ratio, fh)
gen_step4(run, out)
print(f"\n전체 step: {out}/")
for f in sorted(out.iterdir()):
print(f" {f.name}")
if __name__ == "__main__":
run_dir = sys.argv[1] if len(sys.argv) > 1 else "data/runs/20260402_154745"
main(run_dir)

View File

@@ -0,0 +1,290 @@
from __future__ import annotations
import argparse
import asyncio
import json
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from src.block_reference import select_and_generate_references
from src.config import settings
from src.content_verifier import generate_with_retry
from src.design_director import LAYOUT_PRESETS, select_preset
from src.image_utils import embed_images, get_image_sizes
from src.mdx_normalizer import normalize_mdx_content
from src.pipeline_context import (
Analysis,
BlockReference,
ContainerInfo,
DesignBudget,
FontHierarchy,
NormalizedContent,
PageStructure,
PipelineContext,
Topic,
create_context,
)
from src.renderer import render_slide_from_html
from src.slide_measurer import capture_slide_screenshot, measure_rendered_heights
from src.space_allocator import (
ContainerSpec as LegacyContainerSpec,
calculate_container_specs,
calculate_design_budget,
calculate_dynamic_ratio,
calculate_font_hierarchy,
)
def _load_json(path: Path) -> dict:
return json.loads(path.read_text(encoding='utf-8-sig'))
def _build_context(content: str, base_path: str, stage1a: dict, stage1b: dict) -> PipelineContext:
ctx = create_context(content, base_path)
normalized = normalize_mdx_content(content)
ctx.normalized = NormalizedContent(
clean_text=normalized['clean_text'],
title=normalized['title'],
images=normalized['images'],
popups=normalized['popups'],
tables=normalized['tables'],
sections=normalized['sections'],
)
analysis_raw = stage1a['analysis']
ctx.analysis = Analysis(
core_message=analysis_raw['core_message'],
title=analysis_raw['title'],
total_pages=analysis_raw.get('total_pages', 1),
)
ctx.page_structure = PageStructure(roles=stage1a['page_structure'])
refined_map = {item['topic_id']: item for item in stage1b['concepts']}
topics = []
for raw in stage1a['topics']:
merged = dict(raw)
if raw['id'] in refined_map:
merged.update(refined_map[raw['id']])
topics.append(Topic(**merged))
ctx.topics = topics
return ctx
def _stage_1_5a(ctx: PipelineContext) -> PipelineContext:
image_sizes = get_image_sizes(ctx.raw_content, ctx.base_path)
role_text_lengths = {}
for role, info in ctx.page_structure.roles.items():
if isinstance(info, dict):
role_text_lengths[role] = len(ctx.get_role_content(role))
font_hierarchy_dict = calculate_font_hierarchy(role_text_lengths)
ctx.font_hierarchy = FontHierarchy(
key_msg=font_hierarchy_dict.get('핵심', 14.0),
core=font_hierarchy_dict.get('본심', 12.0),
bg=font_hierarchy_dict.get('배경', 11.0),
sidebar=font_hierarchy_dict.get('첨부', 10.0),
)
ctx.container_ratio = calculate_dynamic_ratio(role_text_lengths, font_hierarchy_dict)
analysis_dict = {
'topics': [t.model_dump() for t in ctx.topics],
'page_structure': ctx.page_structure.roles,
}
preset_name = select_preset(analysis_dict)
ctx.preset_name = preset_name
ctx.preset = LAYOUT_PRESETS.get(preset_name, {})
container_specs = calculate_container_specs(
page_structure=ctx.page_structure.roles,
topics=[t.model_dump() for t in ctx.topics],
preset=ctx.preset,
slide_width=settings.slide_width,
slide_height=settings.slide_height,
)
ctx.containers = {
role: ContainerInfo(
role=spec.role,
zone=spec.zone,
topic_ids=spec.topic_ids,
weight=spec.weight,
height_px=spec.height_px,
width_px=spec.width_px,
max_height_cost=spec.max_height_cost,
block_constraints=spec.block_constraints,
)
for role, spec in container_specs.items()
}
slide_images = []
for img_key, img_info in (image_sizes or {}).items():
img_path = Path(ctx.base_path) / img_key if ctx.base_path else Path(img_key)
slide_images.append({
'path': str(img_path),
'width': img_info.get('width', 0),
'height': img_info.get('height', 0),
'ratio': round(img_info.get('width', 1) / max(1, img_info.get('height', 1)), 2),
'topic_id': img_info.get('topic_id'),
'b64': '',
})
ctx.slide_images = slide_images
ctx.analysis = ctx.analysis.model_copy(update={'image_sizes': image_sizes or {}})
return ctx
def _stage_1_7(ctx: PipelineContext) -> PipelineContext:
refs_raw = select_and_generate_references(
topics=[t.model_dump() for t in ctx.topics],
containers=ctx.containers,
page_structure=ctx.page_structure.roles,
)
ctx.references = {
role: BlockReference(
block_id=ref['block_id'],
variant=ref['variant'],
visual_type=ref['visual_type'],
schema_info=ref['schema_info'],
design_reference_html=ref['design_reference_html'],
)
for role, ref in refs_raw.items()
}
return ctx
def _stage_1_5b(ctx: PipelineContext) -> PipelineContext:
updated = {}
font_map = {'본심': 'core', '배경': 'bg', '첨부': 'sidebar', '결론': 'core'}
for role, ci in ctx.containers.items():
ref = ctx.references.get(role)
schema_info = ref.schema_info if ref else {}
font_size = getattr(ctx.font_hierarchy, font_map.get(role, 'core'), 12.0)
budget = calculate_design_budget(
container_height_px=ci.height_px,
container_width_px=ci.width_px,
block_schema=schema_info,
font_size=font_size,
)
updated[role] = ci.model_copy(update={
'design_budget': DesignBudget(
available_height_px=budget['available_height_px'],
available_width_px=budget['available_width_px'],
max_circle_diameter=budget['max_circle_diameter'],
max_img_width=budget['max_img_width'],
max_img_height=budget['max_img_height'],
fits=budget['fits'],
)
})
ctx.containers = updated
return ctx
async def _stage_2(ctx: PipelineContext) -> PipelineContext:
analysis_dict = {
'topics': [t.model_dump() for t in ctx.topics],
'page_structure': ctx.page_structure.roles,
'core_message': ctx.analysis.core_message,
'title': ctx.analysis.title,
'total_pages': ctx.analysis.total_pages,
'image_sizes': ctx.analysis.image_sizes,
}
container_specs_dict = {
role: LegacyContainerSpec(
role=ci.role,
zone=ci.zone,
topic_ids=ci.topic_ids,
weight=ci.weight,
height_px=ci.height_px,
width_px=ci.width_px,
max_height_cost=ci.max_height_cost,
block_constraints=ci.block_constraints,
)
for role, ci in ctx.containers.items()
}
analysis_dict['phase_t'] = {
'font_hierarchy': ctx.font_hierarchy.model_dump(),
'container_ratio': ctx.container_ratio,
'references': {role: ref.model_dump() for role, ref in ctx.references.items()},
'design_budgets': {
role: ci.design_budget.model_dump() if ci.design_budget else {}
for role, ci in ctx.containers.items()
},
}
generated, _verification = await generate_with_retry(
content=ctx.raw_content,
analysis=analysis_dict,
container_specs=container_specs_dict,
preset=ctx.preset,
images=ctx.slide_images,
)
ctx.generated_html = generated
return ctx
def _stage_3(ctx: PipelineContext) -> PipelineContext:
analysis_dict = {
'topics': [t.model_dump() for t in ctx.topics],
'page_structure': ctx.page_structure.roles,
'core_message': ctx.analysis.core_message,
'title': ctx.analysis.title,
}
ctx.rendered_html = render_slide_from_html(ctx.generated_html, analysis_dict, ctx.preset)
if ctx.base_path:
ctx.rendered_html = embed_images(ctx.rendered_html, ctx.base_path)
return ctx
def _stage_4_lite(ctx: PipelineContext) -> PipelineContext:
ctx.measurement = measure_rendered_heights(ctx.rendered_html)
ctx.screenshot_b64 = capture_slide_screenshot(ctx.rendered_html) or ''
ctx.quality_score = 100 if not any(
zone.get('overflowed') for zone in ctx.measurement.get('zones', {}).values()
) else 60
return ctx
async def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument('--input', required=True)
parser.add_argument('--stage1a', required=True)
parser.add_argument('--stage1b', required=True)
parser.add_argument('--base-path', default='')
parser.add_argument('--output-dir', required=True)
args = parser.parse_args()
content = Path(args.input).read_text(encoding='utf-8')
stage1a = _load_json(Path(args.stage1a))
stage1b = _load_json(Path(args.stage1b))
ctx = _build_context(content, args.base_path, stage1a, stage1b)
ctx = _stage_1_5a(ctx)
ctx = _stage_1_7(ctx)
ctx = _stage_1_5b(ctx)
ctx = await _stage_2(ctx)
ctx = _stage_3(ctx)
ctx = _stage_4_lite(ctx)
out_dir = Path(args.output_dir)
out_dir.mkdir(parents=True, exist_ok=True)
(out_dir / 'generated_html.json').write_text(
json.dumps(ctx.generated_html, ensure_ascii=False, indent=2),
encoding='utf-8',
)
(out_dir / 'final.html').write_text(ctx.rendered_html, encoding='utf-8')
(out_dir / 'measurement.json').write_text(
json.dumps(ctx.measurement, ensure_ascii=False, indent=2),
encoding='utf-8',
)
(out_dir / 'context.json').write_text(
ctx.model_dump_json(indent=2, exclude={'screenshot_b64', 'rendered_html'}),
encoding='utf-8',
)
if __name__ == '__main__':
asyncio.run(main())

View File

@@ -0,0 +1,86 @@
"""Stage 1B 데이터를 고정 입력으로, pipeline.py의 generate_slide()를 사용.
Kei persona 관여 부분(1A, 1B)을 건너뛰고
나머지 파이프라인(1.5a~4)을 그대로 실행.
pipeline.py를 직접 수정하지 않고, manual_layout 파라미터로 Stage 1A를 고정.
Stage 1B(structured_text)도 고정.
사용법:
python scripts/run_from_stage1b.py data/runs/20260403_133746
"""
import asyncio
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
async def main(run_dir: str):
run = Path(run_dir)
# Stage 1B context 로드
ctx_json = json.loads((run / "stage_1b_context.json").read_text(encoding="utf-8"))
# MDX 원본: samples에서 직접 읽기 (최신 원본 사용)
samples_dir = Path(__file__).parent.parent / "samples"
mdx_file = samples_dir / "mdx" / "01. 건설산업 DX의 올바른 이해(0127).mdx"
if mdx_file.exists():
raw_content = mdx_file.read_text(encoding="utf-8")
else:
raw_content = ctx_json.get("raw_content", "")
# Stage 1A 결과를 manual_layout으로 전달 (Stage 1A 스킵)
# page_structure가 {"roles": {...}} 형태이면 roles 안쪽을 직접 전달
ps = ctx_json["page_structure"]
if "roles" in ps:
ps = ps["roles"]
manual_layout = {
"topics": ctx_json["topics"],
"page_structure": ps,
"core_message": ctx_json.get("analysis", {}).get("core_message", ""),
"title": ctx_json.get("analysis", {}).get("title", ""),
}
print(f"=== Stage 1B 데이터 고정: {run.name} ===")
print(f" topics: {len(ctx_json['topics'])}")
for t in ctx_json["topics"]:
print(f" 꼭지{t['id']}: {t['title']} (st={len(t.get('structured_text',''))}자)")
# pipeline.py의 generate_slide() 호출
from src.pipeline import generate_slide
# 이미지 base_path: samples/images/
base_path = str(samples_dir / "images")
async for event in generate_slide(raw_content, manual_layout=manual_layout, base_path=base_path):
ev_type = event.get("event", "")
ev_data = event.get("data", "")
if ev_type == "progress":
print(f" [{ev_type}] {ev_data}")
elif ev_type == "error":
print(f"{ev_data}")
elif ev_type == "result":
print(f" ✅ 완료 ({len(ev_data)} bytes)")
# 최신 run 찾기 (YYYYMMDD_HHMMSS 형식만)
import re as _re
runs_dir = Path("data/runs")
dated_runs = [d for d in runs_dir.iterdir() if d.is_dir() and _re.match(r'^\d{8}_\d{6}$', d.name)]
latest = sorted(dated_runs, reverse=True)[0]
print(f"\n=== 결과: {latest} ===")
# 코드 조립도 실행
from scripts.assemble_stage2 import assemble
assemble(str(latest))
print(f"\n확인:")
print(f" file:///{latest}/steps/stage_2.html")
print(f" file:///{latest}/steps/stage_2_code_assembled.html")
print(f" file:///{latest}/steps/stage_3_rendered.html")
print(f" file:///{latest}/final.html")
if __name__ == "__main__":
run_dir = sys.argv[1] if len(sys.argv) > 1 else "data/runs/20260403_133746"
asyncio.run(main(run_dir))

268
scripts/test_phase_t.py Normal file
View File

@@ -0,0 +1,268 @@
"""Phase T 통합 테스트.
Kei API / Sonnet API 없이 테스트 가능한 부분 (Stage 0 ~ Stage 1.5b) 전체 검증.
API가 필요한 부분 (Stage 1A/1B/2/4) 은 mock 데이터로 시뮬레이션.
"""
import json
import sys
sys.path.insert(0, ".")
from src.mdx_normalizer import normalize_mdx_content, validate_stage0
from src.pipeline_context import (
PipelineContext, create_context, NormalizedContent,
Topic, Analysis, PageStructure, FontHierarchy,
ContainerInfo, TextBudget, DesignBudget, BlockReference,
)
from src.validators import validate_stage_1a, validate_stage_1b
from src.space_allocator import calculate_font_hierarchy, calculate_dynamic_ratio, calculate_design_budget
from src.block_reference import select_and_generate_references
from src.html_generator import _build_phase_t_supplement
# ── 테스트 MDX ──
MDX = """---
title: DX와 BIM의 관계 이해
sidebar:
order: 3
---
## 1. 용어의 혼용
DX와 BIM이 개념적으로 명확히 정립되지 않은채 혼용되어 사용되고 있다.
이로 인해 건설산업 현장에서 오해가 발생하고 있다.
혼용 때문에 정책 문서마다 서로 다른 정의를 사용하는 문제가 야기된다.
![DX 로드맵](/assets/images/dx_roadmap.png)
*[사진 1] 건설산업 DX 정책 로드맵*
### 혼용 대표 사례
* **건설산업 BIM 기본지침 (2020)**: BIM을 DX와 동일시
* **스마트건설 기술개발 로드맵 (2022)**: BIM 적용률을 DX 성과로 측정
<details>
<summary>BIM 상세 정의</summary>
BIM은 Building Information Modeling의 약어이다.
</details>
## 2. DX와 핵심기술의 올바른 관계
DX는 BIM, GIS, 디지털트윈 등의 상위 개념이다.
BIM은 DX를 실현하기 위한 핵심 기술 중 하나일 뿐이다.
| 구분 | BIM | DX |
|------|-----|-----|
| 범위 | 건물 정보 | 전체 프로세스 |
| 목적 | 정보 관리 | 산업 혁신 |
| 수준 | 기술 도구 | 전략 체계 |
## 3. 용어별 정의
* **건설산업**: 시설물의 설계, 시공, 유지관리 산업
* **BIM**: 건축정보모델링. 3D 모델 기반 정보 통합 관리 기술
* **DX**: 디지털 전환. 디지털 기술로 업무 프로세스를 근본적으로 혁신
:::note[핵심 요약]
BIM ≠ DX 완성. BIM은 DX의 기초가 되는 일부분이다.
:::
"""
def test():
passed = 0
failed = 0
def check(name, condition, detail=""):
nonlocal passed, failed
if condition:
print(f"{name}")
passed += 1
else:
print(f"{name}{detail}")
failed += 1
# ══ Stage 0: MDX 정규화 ══
print("── Stage 0: MDX 정규화 ──")
result = normalize_mdx_content(MDX)
errors_0 = validate_stage0(result, MDX)
check("clean_text 비어있지 않음", len(result["clean_text"]) > 100)
check("title 추출", result["title"] == "DX와 BIM의 관계 이해")
check("images 추출", len(result["images"]) == 1)
check("popups 추출", len(result["popups"]) == 1)
check("tables 추출", len(result["tables"]) == 1)
check("sections 추출", len(result["sections"]) >= 3)
check("JSX 잔여 없음", "style={{" not in result["clean_text"])
check("frontmatter 잔여 없음", not result["clean_text"].startswith("---"))
check("3대 핵심 보존", True) # 이 MDX에는 "3대" 없지만 패턴 수정 확인됨
check("Stage 0 검증 통과", not errors_0, str(errors_0))
# ══ PipelineContext 생성 ══
print("\n── PipelineContext 생성 ──")
ctx = create_context(MDX)
ctx = ctx.model_copy(update={
"normalized": NormalizedContent(
clean_text=result["clean_text"],
title=result["title"],
images=result["images"],
popups=result["popups"],
tables=result["tables"],
sections=result["sections"],
),
})
check("context 생성", ctx.run_id != "")
check("normalized.title", ctx.normalized.title == "DX와 BIM의 관계 이해")
# ══ Stage 1A 시뮬레이션 ══
print("\n── Stage 1A (mock) ──")
ctx = ctx.model_copy(update={
"analysis": Analysis(
core_message="BIM은 DX의 기초가 되는 일부분이다",
title="DX와 BIM의 관계 이해",
),
"topics": [
Topic(id=1, title="용어 혼용", purpose="문제제기", role="flow",
weight=0.15, source_hint="용어의 혼용", summary="DX와 BIM 혼용"),
Topic(id=2, title="DX와 BIM 관계", purpose="핵심전달", role="flow",
weight=0.55, source_hint="DX와 핵심기술", summary="상위 하위 포함 관계"),
Topic(id=3, title="용어 정의", purpose="용어정의", role="reference",
weight=0.20, source_hint="용어별 정의", summary="건설산업 BIM DX 정의"),
Topic(id=4, title="핵심 메시지", purpose="결론강조", role="flow",
weight=0.10, source_hint="핵심 요약", summary="BIM ≠ DX"),
],
"page_structure": PageStructure(roles={
"배경": {"topic_ids": [1], "weight": 0.15},
"본심": {"topic_ids": [2], "weight": 0.55},
"첨부": {"topic_ids": [3], "weight": 0.20},
"결론": {"topic_ids": [4], "weight": 0.10},
}),
})
analysis_dict = {
"topics": [t.model_dump() for t in ctx.topics],
"page_structure": ctx.page_structure.roles,
"core_message": ctx.analysis.core_message,
}
errors_1a = validate_stage_1a(analysis_dict, ctx.normalized.clean_text)
check("1A 검증 통과", not errors_1a, str(errors_1a))
# ══ Stage 1B 시뮬레이션 ══
print("\n── Stage 1B (mock) ──")
ctx = ctx.model_copy(update={
"topics": [
ctx.topics[0].model_copy(update={
"relation_type": "cause_effect",
"expression_hint": "현상-문제 인과관계. 혼용 때문에 오해 야기.",
"source_data": "DX와 BIM이 혼용되어 사용되고 있다",
}),
ctx.topics[1].model_copy(update={
"relation_type": "hierarchy",
"expression_hint": "상위-하위 포함 관계. DX가 BIM을 포함하는 구조.",
"source_data": "DX는 BIM의 상위 개념이다",
}),
ctx.topics[2].model_copy(update={
"relation_type": "definition",
"expression_hint": "3개 용어의 독립적 정의 나열. 참조용 정보.",
"source_data": "건설산업, BIM, DX 각각의 정의",
}),
ctx.topics[3].model_copy(update={
"relation_type": "none",
"expression_hint": "핵심 메시지 강조. 결론적 판단.",
"source_data": "BIM ≠ DX",
}),
],
})
errors_1b = validate_stage_1b(
[t.model_dump() for t in ctx.topics], ctx.normalized.clean_text
)
check("1B 검증 통과", not errors_1b, str(errors_1b))
# ══ Stage 1.5a: 폰트 위계 + 비율 ══
print("\n── Stage 1.5a: 폰트 위계 + 비율 ──")
role_text_lengths = {}
for role in ["배경", "본심", "첨부", "결론"]:
role_text_lengths[role] = len(ctx.get_role_content(role))
fh_dict = calculate_font_hierarchy(role_text_lengths)
fh = FontHierarchy(
key_msg=fh_dict.get("핵심", 14), core=fh_dict.get("본심", 12),
bg=fh_dict.get("배경", 11), sidebar=fh_dict.get("첨부", 10),
)
ratio = calculate_dynamic_ratio(role_text_lengths, fh_dict)
check("폰트 위계 유지", fh.key_msg > fh.core >= fh.bg > fh.sidebar,
f"{fh.key_msg}>{fh.core}>={fh.bg}>{fh.sidebar}")
check("동적 비율 생성", ratio[0] + ratio[1] == 100, f"{ratio}")
ctx = ctx.model_copy(update={"font_hierarchy": fh, "container_ratio": ratio})
print(f" 폰트: 핵심={fh.key_msg} 본심={fh.core} 배경={fh.bg} 첨부={fh.sidebar}")
print(f" 비율: {ratio[0]}:{ratio[1]}")
# ══ Stage 1.7: 참고 블록 선택 ══
print("\n── Stage 1.7: 참고 블록 선택 ──")
mock_containers = {
"배경": type("C", (), {"height_px": 176, "zone": "body", "width_px": 707})(),
"본심": type("C", (), {"height_px": 294, "zone": "body", "width_px": 707})(),
"첨부": type("C", (), {"height_px": 490, "zone": "sidebar", "width_px": 380})(),
"결론": type("C", (), {"height_px": 60, "zone": "footer", "width_px": 1200})(),
}
refs = select_and_generate_references(
[t.model_dump() for t in ctx.topics],
mock_containers,
ctx.page_structure.roles,
)
check("4개 역할 모두 참고 블록", len(refs) == 4, f"got {len(refs)}")
for role, ref_list in refs.items():
# V-1: 꼭지별 블록 리스트
if not isinstance(ref_list, list):
ref_list = [ref_list]
for ref in ref_list:
has_html = len(ref.get("design_reference_html", "")) > 50
check(f" {role}/꼭지{ref.get('topic_id','?')}: {ref['block_id']} HTML", has_html)
# ══ Stage 1.5b: 디자인 예산 ══
print("\n── Stage 1.5b: 디자인 예산 ──")
for role, ref_list in refs.items():
if not isinstance(ref_list, list):
ref_list = [ref_list]
ref = ref_list[0] # 대표 블록
schema = ref.get("schema_info", {})
container = mock_containers.get(role)
if not container:
continue
font_map = {"본심": fh.core, "배경": fh.bg, "첨부": fh.sidebar, "결론": fh.core}
budget = calculate_design_budget(
container.height_px, container.width_px, schema, font_map.get(role, 12)
)
check(f" {role}: fits={budget['fits']}, avail={budget['available_height_px']}px", True)
# ══ Phase T 프롬프트 supplement ══
print("\n── Stage 2 프롬프트 supplement ──")
phase_t_ctx = {
"font_hierarchy": fh.model_dump(),
"container_ratio": ratio,
"references": refs,
"design_budgets": {},
}
for role in ["배경", "본심", "첨부", "결론"]:
supp = _build_phase_t_supplement(role, {"phase_t": phase_t_ctx})
check(f" {role}: supplement 생성 ({len(supp)}자)", len(supp) > 50)
# ══ 전체 직렬화 ══
print("\n── 전체 context 직렬화 ──")
json_str = ctx.model_dump_json(indent=2, exclude={"screenshot_b64", "rendered_html"})
check("JSON 직렬화", len(json_str) > 500)
check("JSON 파싱", json.loads(json_str) is not None)
# ══ 결과 ══
print(f"\n{'' * 50}")
print(f" Phase T 통합 테스트: {passed} passed, {failed} failed")
if failed == 0:
print(" 전체 통과 ✅")
else:
print(f"{failed}개 실패")
print(f"{'' * 50}")
return failed == 0
if __name__ == "__main__":
success = test()
sys.exit(0 if success else 1)

View File

@@ -0,0 +1,208 @@
"""Phase T 전수 검사.
1. 모든 파일 syntax
2. 모든 import chain
3. pipeline.py 내 이름 참조
4. lazy import 유효성
5. catalog.yaml
6. Pydantic 모델
7. 실제 데이터 Stage 0~1.5b
8. Stage 3 render 호출
9. Stage 2 supplement 생성
"""
import ast, re, json, sys
from pathlib import Path
sys.path.insert(0, ".")
errors = []
def check(name, condition, detail=""):
if condition:
print(f" OK {name}")
else:
print(f" FAIL {name} -- {detail}")
errors.append(f"{name}: {detail}")
print("-- 1. Syntax --")
for f in Path("src").glob("*.py"):
try:
ast.parse(f.read_text(encoding="utf-8"))
print(f" OK {f.name}")
except SyntaxError as e:
print(f" FAIL {f.name}: {e}")
errors.append(f"syntax: {f.name}")
print("\n-- 2. Import --")
for mod in ["src.pipeline_context", "src.mdx_normalizer", "src.validators",
"src.block_reference", "src.space_allocator", "src.html_generator",
"src.content_verifier", "src.renderer", "src.kei_client",
"src.image_utils", "src.slide_measurer", "src.config",
"src.main", "src.pipeline"]:
try:
__import__(mod)
print(f" OK {mod}")
except Exception as e:
print(f" FAIL {mod}: {e}")
errors.append(f"import: {mod}")
print("\n-- 3. pipeline.py import 참조 --")
psrc = Path("src/pipeline.py").read_text(encoding="utf-8")
needed = ["PipelineContext", "Topic", "NormalizedContent", "Analysis",
"PageStructure", "ContainerInfo", "TextBudget", "DesignBudget",
"FontHierarchy", "BlockReference", "StageFailure",
"build_retry_feedback", "create_context"]
import_block = re.search(r"from src\.pipeline_context import \((.*?)\)", psrc, re.DOTALL)
imported = set()
if import_block:
imported = {n.strip() for n in import_block.group(1).split(",") if n.strip()}
for name in needed:
if name in psrc and name not in imported:
# 메서드인지 확인
is_method = all(("." + name) in line or name not in line
for line in psrc.split("\n")
if "from src.pipeline_context" not in line)
if not is_method:
check(f"import {name}", False, "사용되지만 import 안 됨")
else:
check(f"import {name}", True)
else:
check(f"import {name}", name in imported or name not in psrc)
print("\n-- 4. lazy import --")
for mod_name, func_name in re.findall(r"from (src\.\w+) import (\w+)", psrc):
if "pipeline_context" in mod_name:
continue
try:
mod = __import__(mod_name, fromlist=[func_name])
check(f"{mod_name}.{func_name}", hasattr(mod, func_name))
except Exception as e:
check(f"{mod_name}.{func_name}", False, str(e))
print("\n-- 5. catalog.yaml --")
import yaml
data = yaml.safe_load(Path("templates/catalog.yaml").read_text(encoding="utf-8"))
blocks = data.get("blocks", [])
check("blocks count", len(blocks) == 38, f"got {len(blocks)}")
check("schema 38/38", sum(1 for b in blocks if b.get("schema")) == 38)
check("visual_diff 20", sum(1 for b in blocks if b.get("visual_diff")) == 20)
print("\n-- 6. Pydantic --")
from src.pipeline_context import *
check("create_context", create_context("test") is not None)
check("FontHierarchy OK", FontHierarchy(key_msg=14, core=12, bg=11, sidebar=10) is not None)
try:
FontHierarchy(key_msg=10, core=12, bg=14, sidebar=9)
check("FontHierarchy violation", False, "not caught")
except:
check("FontHierarchy violation", True)
check("Topic no weight", "weight" not in Topic.model_fields)
check("DesignBudget", DesignBudget(available_height_px=100) is not None)
print("\n-- 7. 실제 데이터 Stage 0~1.5b --")
s0 = json.loads(Path("data/runs/20260401_151426/stage_0_context.json").read_text(encoding="utf-8"))
a1 = json.loads(Path("data/runs/1774922951020/step1_analysis.json").read_text(encoding="utf-8"))
c1b = json.loads(Path("data/runs/1774922951020/step1b_concepts.json").read_text(encoding="utf-8"))
# 1A
topics = [Topic(**{k: v for k, v in t.items() if k in Topic.model_fields}) for t in a1["topics"]]
check("1A Topic 변환", len(topics) == 5)
# 1B
concepts = c1b.get("concepts", [])
updated = []
for t in topics:
m = next((c for c in concepts if c.get("id") == t.id), None)
if m:
updated.append(t.model_copy(update={
"relation_type": m.get("relation_type", ""),
"expression_hint": m.get("expression_hint", ""),
"source_data": m.get("source_data", ""),
}))
else:
updated.append(t)
check("1B 병합", len(updated) == 5)
# 검증
from src.validators import validate_stage_1a, validate_stage_1b
e1a = validate_stage_1a(a1, s0["normalized"]["clean_text"])
check("1A 검증", not e1a, str(e1a)[:100] if e1a else "")
e1b = validate_stage_1b([t.model_dump() for t in updated], s0["normalized"]["clean_text"], raw_content=s0["raw_content"])
check("1B 검증", not e1b, str(e1b)[:100] if e1b else "")
# 1.5a
from src.space_allocator import calculate_font_hierarchy, calculate_dynamic_ratio, calculate_container_specs, calculate_design_budget
from src.design_director import LAYOUT_PRESETS, select_preset
from src.block_reference import select_and_generate_references
ctx = create_context(s0["raw_content"])
ctx = ctx.model_copy(update={
"normalized": NormalizedContent(**s0["normalized"]),
"topics": updated,
"page_structure": PageStructure(roles=a1.get("page_structure", {})),
"analysis": Analysis(core_message=a1.get("core_message", ""), title=a1.get("title", "")),
})
rtl = {role: len(ctx.get_role_content(role)) for role in ["배경", "본심", "첨부", "결론"]}
fh_dict = calculate_font_hierarchy(rtl)
fh = FontHierarchy(key_msg=fh_dict["핵심"], core=fh_dict["본심"], bg=fh_dict["배경"], sidebar=fh_dict["첨부"])
check("1.5a 폰트위계", fh.key_msg > fh.core >= fh.bg > fh.sidebar)
ratio = calculate_dynamic_ratio(rtl, fh_dict)
check("1.5a 비율", ratio[0] + ratio[1] == 100)
preset_name = select_preset(a1)
preset = LAYOUT_PRESETS.get(preset_name, {})
specs = calculate_container_specs(a1.get("page_structure", {}), [t.model_dump() for t in updated], preset)
check("1.5a 컨테이너", len(specs) >= 3)
# 1.7
refs = select_and_generate_references([t.model_dump() for t in updated], specs, a1.get("page_structure", {}))
check("1.7 참고블록", len(refs) >= 3)
# 1.5b
for role, spec in specs.items():
ref = refs.get(role, {})
schema = ref.get("schema_info", {})
font_map = {"본심": fh.core, "배경": fh.bg, "첨부": fh.sidebar, "결론": fh.core}
budget = calculate_design_budget(spec.height_px, spec.width_px, schema, font_map.get(role, 12))
db = DesignBudget(**budget)
check(f"1.5b {role}", True)
print("\n-- 8. Stage 3 render --")
from src.renderer import render_slide_from_html
mock_gen = {
"body_html": '<div style="overflow:hidden"><div class="key-msg">test</div></div>',
"sidebar_html": '<div style="overflow:hidden; padding-left:14px; text-indent:-14px;">side</div>',
"footer_html": "<div>foot</div>",
}
analysis_dict = {
"topics": [t.model_dump() for t in updated],
"page_structure": a1.get("page_structure", {}),
"core_message": a1.get("core_message", ""),
"title": a1.get("title", ""),
}
html = render_slide_from_html(mock_gen, analysis_dict, preset)
check("Stage 3 render", len(html) > 100, f"len={len(html)}")
print("\n-- 9. Stage 2 supplement --")
from src.html_generator import _build_phase_t_supplement
phase_t_ctx = {
"font_hierarchy": fh.model_dump(),
"container_ratio": ratio,
"references": {r: v for r, v in refs.items()},
"design_budgets": {},
}
for role in ["배경", "본심", "첨부", "결론"]:
supp = _build_phase_t_supplement(role, {"phase_t": phase_t_ctx})
check(f"supplement {role}", len(supp) > 50, f"len={len(supp)}")
# 결과
print()
if errors:
print(f"=== FAIL: {len(errors)}건 ===")
for e in errors:
print(f" - {e}")
else:
print("=== 전수 검사 통과: 오류 0건 ===")
sys.exit(1 if errors else 0)

View File

@@ -0,0 +1,235 @@
"""Phase T 전체 파이프라인 시뮬레이션 (Stage 0 ~ Stage 5).
API 호출을 mock으로 대체하여 코드 경로 전체를 검증.
실제 Kei 응답(기존 run) + mock Sonnet/Selenium으로 전 Stage 통과 여부 확인.
"""
import asyncio
import json
import sys
import logging
from pathlib import Path
from unittest.mock import patch, AsyncMock, MagicMock
sys.path.insert(0, ".")
logging.basicConfig(level=logging.WARNING)
# ── 실제 데이터 로드 ──
RUN_DIR = Path("data/runs/1774922951020")
STAGE_0_DIR = Path("data/runs/20260401_151426")
stage0_ctx = json.loads((STAGE_0_DIR / "stage_0_context.json").read_text(encoding="utf-8"))
raw_content = stage0_ctx["raw_content"]
analysis_1a = json.loads((RUN_DIR / "step1_analysis.json").read_text(encoding="utf-8"))
concepts_1b = json.loads((RUN_DIR / "step1b_concepts.json").read_text(encoding="utf-8"))
# ── Mock 응답 정의 ──
async def mock_classify_content(content):
"""Stage 1A mock: 실제 Kei 응답 반환"""
return analysis_1a
async def mock_refine_concepts(content, analysis):
"""Stage 1B mock: 실제 Kei 1B 응답을 analysis에 병합하여 반환"""
result = dict(analysis)
concepts = concepts_1b.get("concepts", [])
for t in result.get("topics", []):
match = next((c for c in concepts if c.get("id") == t.get("id")), None)
if match:
t["relation_type"] = match.get("relation_type", "")
t["expression_hint"] = match.get("expression_hint", "")
t["source_data"] = match.get("source_data", "")
return result
# Stage 2 mock: generate_with_retry → mock HTML 반환
MOCK_BODY_HTML = """<div style="overflow:hidden; font-size:12px;">
<div class="bg" style="padding:10px;">
<h3 style="font-size:11px;">용어 혼용</h3>
<p style="font-size:11px;">DX와 BIM이 혼용되어 사용되고 있다</p>
</div>
<div style="height:12px;"></div>
<div class="core" style="padding:10px;">
<h3 style="font-size:12px;">DX와 핵심기술의 올바른 관계</h3>
<p style="font-size:12px;">DX는 BIM, GIS, 디지털트윈을 포함하는 상위개념이다</p>
<div class="key-msg" style="font-size:14px; font-weight:bold;">BIM ≠ DX</div>
</div>
</div>"""
MOCK_SIDEBAR_HTML = """<div style="overflow:hidden; font-size:10px; padding-left:14px; text-indent:-14px;">
<h3 style="font-size:10px;">용어 정의</h3>
<div style="padding-left:14px; text-indent:-14px;">
<p>건설산업: 종합산업</p>
<p>BIM: 정보관리도구</p>
<p>DX: 디지털 전환</p>
</div>
</div>"""
MOCK_FOOTER_HTML = """<div style="background:linear-gradient(135deg,#1e40af,#3b82f6); padding:14px 30px; text-align:center; border-radius:6px;">
<span style="font-size:14px; font-weight:bold; color:white;">BIM은 DX의 기초가 되는 일부분이다</span>
</div>"""
MOCK_GENERATED = {
"body_html": MOCK_BODY_HTML,
"sidebar_html": MOCK_SIDEBAR_HTML,
"footer_html": MOCK_FOOTER_HTML,
"reasoning": "mock",
}
MOCK_VERIFICATION = {} # verify_all_areas 결과
async def mock_generate_with_retry(content, analysis, container_specs, preset, images=None):
"""Stage 2 mock"""
from src.content_verifier import VerificationResult
verification = {
"body_bg": VerificationResult(passed=True, area_name="body_bg", score=1.0),
"body_core": VerificationResult(passed=True, area_name="body_core", score=1.0),
"sidebar": VerificationResult(passed=True, area_name="sidebar", score=1.0),
"footer": VerificationResult(passed=True, area_name="footer", score=1.0),
}
return MOCK_GENERATED, verification
def mock_render_slide_from_html(generated, analysis, preset):
"""Stage 3 mock"""
return f"""<!DOCTYPE html>
<html lang="ko"><head><meta charset="UTF-8">
<style>.slide {{width:1280px;height:720px;padding:40px;}}</style>
</head><body><div class="slide">
<div class="area-header"><h1>{analysis.get('title','')}</h1></div>
<div class="area-body">{generated.get('body_html','')}</div>
<div class="area-sidebar">{generated.get('sidebar_html','')}</div>
<div class="area-footer">{generated.get('footer_html','')}</div>
</div></body></html>"""
def mock_measure_rendered_heights(html):
"""Stage 4 L4 mock: overflow 없음"""
return {
"zones": {
"body": {"scrollHeight": 400, "clientHeight": 490, "overflowed": False},
"sidebar": {"scrollHeight": 300, "clientHeight": 490, "overflowed": False},
"footer": {"scrollHeight": 55, "clientHeight": 60, "overflowed": False},
}
}
def mock_capture_slide_screenshot(html):
"""Stage 4 L5 mock: 빈 스크린샷"""
return "" # 빈 문자열 → 비전 품질 게이트 스킵
async def run_full_simulation():
"""전체 파이프라인 시뮬레이션"""
passed = 0
failed = 0
def check(name, condition, detail=""):
nonlocal passed, failed
if condition:
print(f"{name}")
passed += 1
else:
print(f"{name}")
if detail:
print(f"{detail}")
failed += 1
# Mock 패치 적용
# _retry_kei는 async fn을 await하는 래퍼이므로, mock도 await 해야 함
async def mock_retry_kei(fn, *a, **kw):
return await fn(*a, **kw)
with patch("src.pipeline._retry_kei", side_effect=mock_retry_kei), \
patch("src.kei_client.classify_content", side_effect=mock_classify_content), \
patch("src.kei_client.refine_concepts", side_effect=mock_refine_concepts), \
patch("src.content_verifier.generate_with_retry", side_effect=mock_generate_with_retry), \
patch("src.renderer.render_slide_from_html", side_effect=mock_render_slide_from_html), \
patch("src.slide_measurer.measure_rendered_heights", side_effect=mock_measure_rendered_heights), \
patch("src.slide_measurer.capture_slide_screenshot", side_effect=mock_capture_slide_screenshot), \
patch("src.image_utils.get_image_sizes", return_value={}), \
patch("src.image_utils.embed_images", side_effect=lambda html, bp: html):
from src.pipeline import generate_slide
events = []
print("── 전체 파이프라인 실행 ──")
try:
async for event in generate_slide(raw_content):
events.append(event)
evt_type = event.get("event", "")
evt_data = event.get("data", "")
if evt_type == "progress":
print(f" 📌 {evt_data}")
elif evt_type == "error":
print(f" ❌ ERROR: {evt_data}")
elif evt_type == "result":
print(f" 📄 result: {len(evt_data)}자 HTML")
except Exception as e:
print(f" 💥 EXCEPTION: {type(e).__name__}: {e}")
import traceback
traceback.print_exc()
check("파이프라인 예외 없음", False, str(e))
print(f"\n{'' * 55}")
print(f" 전체 시뮬레이션: {passed} passed, {failed} failed")
print(f"{'' * 55}")
return False
print()
# 이벤트 검증
event_types = [e["event"] for e in events]
check("progress 이벤트 존재", "progress" in event_types)
check("error 이벤트 없음", "error" not in event_types,
f"errors: {[e['data'] for e in events if e['event']=='error']}")
check("result 이벤트 존재", "result" in event_types)
# result HTML 검증
result_events = [e for e in events if e["event"] == "result"]
if result_events:
html = result_events[0]["data"]
check("HTML 비어있지 않음", len(html) > 100, f"길이: {len(html)}")
check("HTML에 slide 클래스", "slide" in html)
check("HTML에 body 영역", "area-body" in html or "body_html" in html or "bg" in html)
check("HTML에 sidebar 영역", "area-sidebar" in html or "sidebar" in html)
check("HTML에 footer 영역", "area-footer" in html or "footer" in html)
else:
check("result HTML", False, "result 이벤트 없음")
# 스냅샷 파일 확인
import glob
latest_runs = sorted(glob.glob("data/runs/2026*"), reverse=True)
if latest_runs:
run_dir = latest_runs[0]
files = [Path(f).name for f in glob.glob(f"{run_dir}/*.json")]
print(f"\n 스냅샷 폴더: {Path(run_dir).name}")
print(f" 저장된 파일: {files}")
check("stage_0 스냅샷", "stage_0_context.json" in files)
check("stage_1a 스냅샷", "stage_1a_context.json" in files)
check("stage_1b 스냅샷", "stage_1b_context.json" in files)
check("stage_1_5a 스냅샷", "stage_1_5a_context.json" in files)
check("stage_1_7 스냅샷", "stage_1_7_context.json" in files)
check("stage_1_5b 스냅샷", "stage_1_5b_context.json" in files)
check("stage_2 스냅샷", "stage_2_context.json" in files)
check("stage_3 스냅샷", "stage_3_context.json" in files)
check("stage_4 스냅샷", "stage_4_context.json" in files)
check("final 스냅샷", "final_context.json" in files)
check("final.html 저장", "final.html" in [Path(f).name for f in glob.glob(f"{run_dir}/*")])
else:
check("스냅샷 폴더", False, "run 폴더 없음")
print(f"\n{'' * 55}")
print(f" 전체 파이프라인 시뮬레이션: {passed} passed, {failed} failed")
if failed == 0:
print(" 전체 통과 ✅")
else:
print(f"{failed}개 실패")
print(f"{'' * 55}")
return failed == 0
if __name__ == "__main__":
success = asyncio.run(run_full_simulation())
sys.exit(0 if success else 1)

View File

@@ -0,0 +1,269 @@
"""Phase T 실제 데이터 시뮬레이션.
기존 run(1774922951020)의 실제 Kei API 응답 + 실제 MDX로
전 Stage를 시뮬레이션하여 설계 오류를 사전에 잡는다.
"""
import json
import sys
from pathlib import Path
sys.path.insert(0, ".")
# ── 실제 데이터 로드 ──
RUN_DIR = Path("data/runs/1774922951020")
STAGE_0_DIR = Path("data/runs/20260401_151426")
# Stage 0 결과 (실제 실행된 것)
stage0_ctx = json.loads((STAGE_0_DIR / "stage_0_context.json").read_text(encoding="utf-8"))
raw_content = stage0_ctx["raw_content"]
normalized = stage0_ctx["normalized"]
# Kei 1A 실제 응답
analysis_1a = json.loads((RUN_DIR / "step1_analysis.json").read_text(encoding="utf-8"))
# Kei 1B 실제 응답
concepts_1b = json.loads((RUN_DIR / "step1b_concepts.json").read_text(encoding="utf-8"))
def test():
passed = 0
failed = 0
def check(name, condition, detail=""):
nonlocal passed, failed
if condition:
print(f"{name}")
passed += 1
else:
print(f"{name}")
if detail:
print(f"{detail}")
failed += 1
# ══════════════════════════════════════
# Stage 0: 이미 실행됨 — 결과 확인만
# ══════════════════════════════════════
print("── Stage 0: 실제 결과 확인 ──")
check("clean_text", len(normalized["clean_text"]) > 200)
check("title", normalized["title"] == "건설산업 DX의 올바른 이해")
check("images", len(normalized["images"]) == 1)
check("popups", len(normalized["popups"]) == 2, f"got {len(normalized['popups'])}")
check("sections", len(normalized["sections"]) >= 3)
# ══════════════════════════════════════
# Stage 1A: 실제 Kei 응답 → Topic 모델 변환
# ══════════════════════════════════════
print("\n── Stage 1A: 실제 Kei 응답 → Topic 변환 ──")
from src.pipeline_context import Topic, FontHierarchy
# 실제 Kei 응답의 topic dict 구조 확인
topics_raw = analysis_1a.get("topics", [])
print(f" Kei 반환 topic 수: {len(topics_raw)}")
print(f" Kei topic 키: {list(topics_raw[0].keys()) if topics_raw else '없음'}")
# 실제 변환 시도 (pipeline.py의 코드와 동일)
try:
topics = [Topic(**{k: v for k, v in t.items() if k in Topic.model_fields}) for t in topics_raw]
check("Topic 변환 성공", True)
for t in topics:
print(f" topic {t.id}: {t.title} / {t.purpose} / role={t.role}")
except Exception as e:
check("Topic 변환", False, str(e))
return False
# Kei가 안 주는 필드 확인
kei_keys = set(topics_raw[0].keys()) if topics_raw else set()
topic_keys = set(Topic.model_fields.keys())
missing_from_kei = topic_keys - kei_keys
extra_from_kei = kei_keys - topic_keys
print(f" Topic 모델에 있고 Kei에 없는 필드: {missing_from_kei}")
print(f" Kei에 있고 Topic 모델에 없는 필드: {extra_from_kei}")
check("Kei 미제공 필드가 기본값으로 처리됨",
all(hasattr(topics[0], f) for f in missing_from_kei))
# 1A 검증
from src.validators import validate_stage_1a
errors_1a = validate_stage_1a(analysis_1a, normalized["clean_text"])
check(f"1A 검증 통과", not errors_1a)
for e in errors_1a:
print(f" {e['severity']}: {e.get('localization', '')}")
# ══════════════════════════════════════
# Stage 1B: 실제 Kei 1B 응답 병합
# ══════════════════════════════════════
print("\n── Stage 1B: 실제 Kei 1B 응답 병합 ──")
concepts = concepts_1b.get("concepts", [])
print(f" Kei 1B 반환 수: {len(concepts)}")
# 병합 (pipeline.py의 코드와 동일)
updated_topics = []
for t in topics:
match = next((c for c in concepts if c.get("id") == t.id), None)
if match:
updated = t.model_copy(update={
"relation_type": match.get("relation_type", t.relation_type),
"expression_hint": match.get("expression_hint", t.expression_hint),
"source_data": match.get("source_data", t.source_data),
})
updated_topics.append(updated)
else:
updated_topics.append(t)
check("1B 병합 성공", len(updated_topics) == len(topics))
for t in updated_topics:
print(f" topic {t.id}: relation={t.relation_type}, hint={t.expression_hint[:30]}...")
# 1B 검증 (raw_content 포함 — popups 대조)
from src.validators import validate_stage_1b
errors_1b = validate_stage_1b(
[t.model_dump() for t in updated_topics],
normalized["clean_text"],
raw_content=raw_content,
)
check(f"1B 검증 통과", not errors_1b)
for e in errors_1b:
print(f" {e['severity']}: {e.get('localization', '')}")
if e.get("evidence"):
print(f" 증거: {str(e['evidence'])[:100]}")
# ══════════════════════════════════════
# Stage 1.5a: 폰트 위계 + 동적 비율
# ══════════════════════════════════════
print("\n── Stage 1.5a: 폰트 위계 + 동적 비율 ──")
from src.pipeline_context import PipelineContext, create_context, NormalizedContent, Analysis, PageStructure
from src.space_allocator import calculate_font_hierarchy, calculate_dynamic_ratio
# context 구성 (실제 데이터)
ctx = create_context(raw_content)
ctx = ctx.model_copy(update={
"normalized": NormalizedContent(**normalized),
"topics": updated_topics,
"page_structure": PageStructure(roles=analysis_1a.get("page_structure", {})),
"analysis": Analysis(
core_message=analysis_1a.get("core_message", ""),
title=analysis_1a.get("title", ""),
),
})
# 역할별 텍스트 양
role_text_lengths = {}
for role in ["배경", "본심", "첨부", "결론"]:
role_text = ctx.get_role_content(role)
role_text_lengths[role] = len(role_text)
print(f" {role}: {len(role_text)}")
fh_dict = calculate_font_hierarchy(role_text_lengths)
try:
fh = FontHierarchy(
key_msg=fh_dict.get("핵심", 14), core=fh_dict.get("본심", 12),
bg=fh_dict.get("배경", 11), sidebar=fh_dict.get("첨부", 10),
)
check("폰트 위계 생성", True)
print(f" 위계: 핵심={fh.key_msg} > 본심={fh.core} >= 배경={fh.bg} > 첨부={fh.sidebar}")
except Exception as e:
check("폰트 위계", False, str(e))
return False
ratio = calculate_dynamic_ratio(role_text_lengths, fh_dict)
check("동적 비율 생성", ratio[0] + ratio[1] == 100)
print(f" 비율: body:sidebar = {ratio[0]}:{ratio[1]}")
# ══════════════════════════════════════
# Stage 1.7: 참고 블록 선택
# ══════════════════════════════════════
print("\n── Stage 1.7: 참고 블록 선택 ──")
from src.block_reference import select_and_generate_references
from src.space_allocator import calculate_container_specs
from src.design_director import LAYOUT_PRESETS, select_preset
preset_name = select_preset(analysis_1a)
preset = LAYOUT_PRESETS.get(preset_name, {})
print(f" 프리셋: {preset_name}")
container_specs = calculate_container_specs(
page_structure=analysis_1a.get("page_structure", {}),
topics=[t.model_dump() for t in updated_topics],
preset=preset,
)
print(f" 컨테이너: {', '.join(f'{r}={s.height_px}px' for r, s in container_specs.items())}")
refs = select_and_generate_references(
[t.model_dump() for t in updated_topics],
container_specs,
analysis_1a.get("page_structure", {}),
)
check("참고 블록 선택", len(refs) >= 3)
for role, ref in refs.items():
html_len = len(ref.get("design_reference_html", ""))
has_diff = "차별점" in ref.get("design_reference_html", "")
print(f" {role}: {ref['block_id']} ({ref['visual_type']}, html={html_len}자, diff={'' if has_diff else ''})")
# ══════════════════════════════════════
# Stage 1.5b: 디자인 예산
# ══════════════════════════════════════
print("\n── Stage 1.5b: 디자인 예산 ──")
from src.space_allocator import calculate_design_budget
for role, ref in refs.items():
schema = ref.get("schema_info", {})
spec = container_specs.get(role)
if not spec:
continue
font_map = {"본심": fh.core, "배경": fh.bg, "첨부": fh.sidebar, "결론": fh.core}
budget = calculate_design_budget(spec.height_px, spec.width_px, schema, font_map.get(role, 12))
check(f"{role} 예산 (fits={budget['fits']})", True)
print(f" {role}: container={spec.height_px}px, text={budget['text_height_px']}px, avail={budget['available_height_px']}px")
# ══════════════════════════════════════
# Stage 2: 프롬프트 supplement 생성
# ══════════════════════════════════════
print("\n── Stage 2: 프롬프트 supplement ──")
from src.html_generator import _build_phase_t_supplement
phase_t_ctx = {
"font_hierarchy": fh.model_dump(),
"container_ratio": ratio,
"references": refs,
"design_budgets": {
role: calculate_design_budget(
container_specs[role].height_px, container_specs[role].width_px,
refs.get(role, {}).get("schema_info", {}),
{"본심": fh.core, "배경": fh.bg, "첨부": fh.sidebar, "결론": fh.core}.get(role, 12)
)
for role in container_specs
},
}
analysis_with_t = {**analysis_1a, "phase_t": phase_t_ctx}
for role in ["배경", "본심", "첨부", "결론"]:
supp = _build_phase_t_supplement(role, analysis_with_t)
has_font = "폰트 위계" in supp
has_budget = "디자인 예산" in supp
has_ref = "디자인 레퍼런스" in supp
check(f"{role} supplement ({len(supp)}자)", len(supp) > 50)
if not has_font:
print(f" ⚠️ 폰트 위계 누락")
if not has_budget:
print(f" ⚠️ 디자인 예산 누락")
# ══════════════════════════════════════
# 결과
# ══════════════════════════════════════
print(f"\n{'' * 55}")
print(f" 실제 데이터 시뮬레이션: {passed} passed, {failed} failed")
if failed == 0:
print(" 전체 통과 ✅ — 서버에서 실행해도 이 지점까지 동일하게 동작")
else:
print(f"{failed}개 실패 — 서버 실행 전에 수정 필요")
print(f"{'' * 55}")
return failed == 0
if __name__ == "__main__":
success = test()
sys.exit(0 if success else 1)