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:
562
scripts/assemble_stage2.py
Normal file
562
scripts/assemble_stage2.py
Normal 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
167
scripts/gen_viz_layers.py
Normal 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("완료")
|
||||
314
scripts/generate_step_html.py
Normal file
314
scripts/generate_step_html.py
Normal 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)
|
||||
290
scripts/run_from_artifacts.py
Normal file
290
scripts/run_from_artifacts.py
Normal 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())
|
||||
|
||||
|
||||
86
scripts/run_from_stage1b.py
Normal file
86
scripts/run_from_stage1b.py
Normal 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
268
scripts/test_phase_t.py
Normal 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이 개념적으로 명확히 정립되지 않은채 혼용되어 사용되고 있다.
|
||||
이로 인해 건설산업 현장에서 오해가 발생하고 있다.
|
||||
혼용 때문에 정책 문서마다 서로 다른 정의를 사용하는 문제가 야기된다.
|
||||
|
||||

|
||||
*[사진 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)
|
||||
208
scripts/test_phase_t_audit.py
Normal file
208
scripts/test_phase_t_audit.py
Normal 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)
|
||||
235
scripts/test_phase_t_full.py
Normal file
235
scripts/test_phase_t_full.py
Normal 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)
|
||||
269
scripts/test_phase_t_real.py
Normal file
269
scripts/test_phase_t_real.py
Normal 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)
|
||||
Reference in New Issue
Block a user