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:
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)
|
||||
Reference in New Issue
Block a user