Files
C.E.L_Slide_test2/scripts/generate_run_report.py
kyeongmin b0bcffc0f6 Phase N+O: 컨테이너 기반 레이아웃 + Step B 제거 + 전면 정리
- Phase N: catalog 개선, fallback 전면 제거, Kei API 무한 재시도, topic_id 버그 수정
- Phase O: 컨테이너 스펙 계산(비중→px), 블록 스펙 확정, 렌더러 container div
- Step B(Sonnet) 제거: Kei(A-2)+코드로 대체. STEP_B_PROMPT/fallback/DOWNGRADE_MAP 삭제
- Selenium: container div 감지 추가
- catalog.yaml: ref_chars 구조 변환 + FAISS 재빌드
- 문서 전면 갱신: README, PROGRESS, IMPROVEMENT, Phase I~O md

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 15:20:51 +09:00

388 lines
16 KiB
Python

"""파이프라인 실행 리포트 생성기.
data/runs/{run_id}/ 의 중간 산출물을 읽어
단계별 진행 과정을 한눈에 볼 수 있는 HTML 리포트를 생성한다.
사용법:
python scripts/generate_run_report.py # 최신 run
python scripts/generate_run_report.py 1774572796252 # 특정 run
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).parent.parent
RUNS_DIR = PROJECT_ROOT / "data" / "runs"
def load_json(path: Path) -> dict | list | None:
if not path.exists():
return None
with open(path, encoding="utf-8") as f:
return json.load(f)
def load_text(path: Path) -> str | None:
if not path.exists():
return None
return path.read_text(encoding="utf-8")
def generate_report(run_id: str) -> str:
run_dir = RUNS_DIR / run_id
if not run_dir.exists():
return f"<html><body><h1>Run not found: {run_id}</h1></body></html>"
# 데이터 로드
step1 = load_json(run_dir / "step1_analysis.json")
step1b = load_json(run_dir / "step1b_concepts.json")
step2 = load_json(run_dir / "step2_layout.json")
step2b = load_json(run_dir / "step2b_allocation.json")
step3 = load_json(run_dir / "step3_filled_blocks.json")
step4_css = load_json(run_dir / "step4_css_adjustment.json")
step4_measure = load_json(run_dir / "step4_measurement_round1.json")
step5 = load_json(run_dir / "step5_review_round1.json")
final_html = load_text(run_dir / "final.html")
html = f"""<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>파이프라인 리포트 — Run {run_id}</title>
<style>
@import url('https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css');
* {{ margin:0; padding:0; box-sizing:border-box; }}
body {{ font-family:'Pretendard Variable',sans-serif; background:#f1f5f9; color:#1e293b; line-height:1.7; }}
.container {{ max-width:1200px; margin:0 auto; padding:40px 20px; }}
h1 {{ font-size:28px; font-weight:900; margin-bottom:8px; }}
.run-id {{ font-size:14px; color:#64748b; margin-bottom:40px; }}
.step {{ background:#fff; border-radius:12px; padding:28px 32px; margin-bottom:24px; box-shadow:0 1px 3px rgba(0,0,0,0.08); }}
.step-header {{ display:flex; align-items:center; gap:14px; margin-bottom:16px; }}
.step-badge {{ background:#2563eb; color:#fff; font-size:13px; font-weight:700; padding:4px 14px; border-radius:20px; white-space:nowrap; }}
.step-badge.code {{ background:#16a34a; }}
.step-badge.sonnet {{ background:#f59e0b; color:#1e293b; }}
.step-title {{ font-size:20px; font-weight:800; }}
.step-desc {{ font-size:14px; color:#64748b; margin-bottom:16px; }}
table {{ border-collapse:collapse; width:100%; margin:12px 0; }}
th {{ background:#1e293b; color:#fff; padding:10px 14px; text-align:left; font-size:13px; font-weight:700; }}
td {{ padding:8px 14px; border-bottom:1px solid #e2e8f0; font-size:13px; vertical-align:top; }}
tr:nth-child(even) td {{ background:#f8fafc; }}
.json-block {{ background:#f8fafc; border:1px solid #e2e8f0; border-radius:8px; padding:16px; font-family:'Consolas',monospace; font-size:12px; white-space:pre-wrap; word-break:break-all; max-height:400px; overflow-y:auto; }}
.tag {{ display:inline-block; padding:2px 10px; border-radius:12px; font-size:11px; font-weight:700; margin:2px; }}
.tag-purpose {{ background:#dbeafe; color:#1e40af; }}
.tag-role {{ background:#f0fdf4; color:#166534; }}
.tag-layer {{ background:#fef3c7; color:#92400e; }}
.tag-relation {{ background:#fce7f3; color:#9d174d; }}
.tag-area {{ background:#e0e7ff; color:#3730a3; }}
.tag-type {{ background:#f1f5f9; color:#334155; border:1px solid #cbd5e1; }}
.arrow {{ text-align:center; font-size:28px; color:#94a3b8; padding:8px 0; }}
.highlight {{ background:#fef9c3; padding:2px 6px; border-radius:4px; }}
.warn {{ color:#dc2626; font-weight:700; }}
.ok {{ color:#16a34a; font-weight:700; }}
.weight-bar {{ height:20px; border-radius:4px; display:inline-block; vertical-align:middle; }}
.final-preview {{ border:2px solid #2563eb; border-radius:12px; overflow:hidden; margin-top:16px; }}
.final-preview iframe {{ width:1280px; height:720px; border:none; transform-origin:top left; }}
.overflow-row {{ background:#fef2f2 !important; }}
</style>
</head>
<body>
<div class="container">
<h1>Design Agent 파이프라인 리포트</h1>
<div class="run-id">Run ID: {run_id} | 생성: {_ts_to_str(run_id)}</div>
"""
# ── Step 1A ──
if step1:
topics = step1.get("topics", [])
page_struct = step1.get("page_structure", {})
html += f"""
<div class="step">
<div class="step-header">
<span class="step-badge">Kei 실장</span>
<span class="step-title">Step 1A: 꼭지 추출 + 스토리라인 설계</span>
</div>
<div class="step-desc">원본 콘텐츠를 분석하여 핵심 메시지, 꼭지 구조, 페이지 비중을 설계한다.</div>
<table>
<tr><th>항목</th><th>값</th></tr>
<tr><td>제목</td><td><strong>{step1.get('title','')}</strong></td></tr>
<tr><td>핵심 메시지</td><td class="highlight">{step1.get('core_message','')}</td></tr>
<tr><td>정보 구조</td><td>{step1.get('info_structure','')[:200]}</td></tr>
</table>
<h3 style="margin:16px 0 8px; font-size:15px;">페이지 구조 (비중)</h3>
<table>
<tr><th>역할</th><th>topic_ids</th><th>비중(weight)</th><th>시각화</th></tr>
"""
colors = {"본심": "#2563eb", "배경": "#64748b", "첨부": "#f59e0b", "결론": "#16a34a"}
for role, info in page_struct.items():
if isinstance(info, dict):
w = info.get("weight", 0)
tids = info.get("topic_ids", [])
c = colors.get(role, "#94a3b8")
bar_w = int(w * 400)
html += f'<tr><td><strong>{role}</strong></td><td>{tids}</td><td>{w:.0%}</td>'
html += f'<td><span class="weight-bar" style="width:{bar_w}px; background:{c};"></span></td></tr>\n'
html += "</table>\n"
html += """<h3 style="margin:16px 0 8px; font-size:15px;">꼭지 목록</h3>
<table>
<tr><th>#</th><th>제목</th><th>purpose</th><th>role</th><th>layer</th><th>section_title</th></tr>
"""
for t in topics:
st = t.get("section_title", "")
html += f"""<tr>
<td>{t.get('id','')}</td>
<td>{t.get('title','')}</td>
<td><span class="tag tag-purpose">{t.get('purpose','')}</span></td>
<td><span class="tag tag-role">{t.get('role','')}</span></td>
<td><span class="tag tag-layer">{t.get('layer','')}</span></td>
<td>{st if st else '-'}</td>
</tr>\n"""
html += "</table></div>\n"
html += '<div class="arrow">▼</div>\n'
# ── Step 1B ──
if step1b:
concepts = step1b.get("concepts", [])
html += f"""
<div class="step">
<div class="step-header">
<span class="step-badge">Kei 실장</span>
<span class="step-title">Step 1B: 컨셉 구체화</span>
</div>
<div class="step-desc">각 꼭지의 관계 성격(relation_type), 표현 힌트(expression_hint), 원본 데이터를 구체화한다.</div>
<table>
<tr><th>#</th><th>제목</th><th>relation_type</th><th>expression_hint</th><th>source_data</th></tr>
"""
for c in concepts:
tid = c.get("topic_id") or c.get("id", "?")
html += f"""<tr>
<td>{tid}</td>
<td>{c.get('title','')}</td>
<td><span class="tag tag-relation">{c.get('relation_type','')}</span></td>
<td style="max-width:300px">{c.get('expression_hint','')[:120]}</td>
<td style="max-width:300px">{c.get('source_data','')[:120]}</td>
</tr>\n"""
html += "</table></div>\n"
html += '<div class="arrow">▼</div>\n'
# ── Step 2 ──
if step2:
blocks = step2.get("blocks", [])
overflows = step2.get("overflow", [])
html += f"""
<div class="step">
<div class="step-header">
<span class="step-badge">Kei 실장</span>
<span class="step-title">Step 2 (A-2 + B): 블록 배치</span>
</div>
<div class="step-desc">
<strong>Step A:</strong> 규칙 기반 프리셋 선택<br>
<strong>Step A-2 (Kei):</strong> 각 꼭지에 적합한 블록 확정 (코드 레벨 강제)<br>
<strong>Step B (Sonnet):</strong> zone 배치 + char_guide만 결정 (블록 타입 변경 불가)
</div>
<p><strong>프리셋:</strong> <code>{step2.get('preset','')}</code></p>
<table>
<tr><th>area</th><th>블록 타입</th><th>purpose</th><th>topic</th><th>이유</th><th>크기</th></tr>
"""
for b in blocks:
html += f"""<tr>
<td><span class="tag tag-area">{b.get('area','')}</span></td>
<td><span class="tag tag-type">{b.get('type','')}</span></td>
<td><span class="tag tag-purpose">{b.get('purpose','')}</span></td>
<td>{b.get('topic_id','')}</td>
<td style="max-width:300px">{b.get('reason','')[:100]}</td>
<td>{b.get('size','')}</td>
</tr>\n"""
html += "</table>\n"
if overflows:
html += '<h3 style="margin:16px 0 8px; font-size:15px; color:#dc2626;">높이 초과 예상</h3>\n<table>\n'
html += '<tr><th>zone</th><th>예산(px)</th><th>합계(px)</th><th>초과(px)</th></tr>\n'
for o in overflows:
html += f'<tr class="overflow-row"><td>{o.get("area","")}</td><td>{o.get("budget_px","")}</td><td>{o.get("total_px","")}</td><td class="warn">+{o.get("overflow_px","")}</td></tr>\n'
html += "</table>\n"
html += "</div>\n"
html += '<div class="arrow">▼</div>\n'
# ── Step 2B (Allocation) ──
if step2b:
html += f"""
<div class="step">
<div class="step-header">
<span class="step-badge code">코드 (결정론적)</span>
<span class="step-title">Step 2B: 공간 할당</span>
</div>
<div class="step-desc">Kei의 비중(weight)을 기반으로 각 zone 내 블록별 max_height_px와 max_chars를 수학적으로 계산한다.</div>
<div class="json-block">{json.dumps(step2b, ensure_ascii=False, indent=2)}</div>
</div>
"""
html += '<div class="arrow">▼</div>\n'
# ── Step 3 ──
if step3:
filled = step3.get("blocks", [])
html += f"""
<div class="step">
<div class="step-header">
<span class="step-badge">Kei 편집자</span>
<span class="step-title">Step 3: 텍스트 편집</span>
</div>
<div class="step-desc">원본 콘텐츠에서 각 블록의 슬롯에 맞는 텍스트를 추출/편집한다. 원본 보존 원칙.</div>
<table>
<tr><th>area</th><th>블록 타입</th><th>topic</th><th>글자 수</th><th>데이터 (요약)</th></tr>
"""
for b in filled:
data_str = json.dumps(b.get("data", {}), ensure_ascii=False)
preview = data_str[:200] + ("..." if len(data_str) > 200 else "")
html += f"""<tr>
<td><span class="tag tag-area">{b.get('area','')}</span></td>
<td><span class="tag tag-type">{b.get('type','')}</span></td>
<td>{b.get('topic_id','')}</td>
<td>{b.get('char_count','')}</td>
<td style="max-width:400px; font-size:12px; word-break:break-all;">{preview}</td>
</tr>\n"""
html += "</table></div>\n"
html += '<div class="arrow">▼</div>\n'
# ── Step 4 (CSS + Measurement) ──
html += f"""
<div class="step">
<div class="step-header">
<span class="step-badge sonnet">Sonnet 실무자</span>
<span class="step-title">Step 4: CSS 조정 + 렌더링</span>
</div>
<div class="step-desc">텍스트 양에 맞게 CSS 변수(폰트, 여백)를 조정하고 Jinja2로 HTML을 조립한다.</div>
"""
if step4_css:
html += f'<div class="json-block">{json.dumps(step4_css, ensure_ascii=False, indent=2)}</div>\n'
if step4_measure:
slide = step4_measure.get("slide", {})
zones = step4_measure.get("zones", {})
slide_status = '<span class="ok">OK</span>' if not slide.get("overflowed") else f'<span class="warn">+{slide.get("excess_px",0)}px 초과</span>'
html += f"""
<h3 style="margin:16px 0 8px; font-size:15px;">Phase L: Selenium 렌더링 측정</h3>
<p>슬라이드 전체: {slide.get('scrollHeight','?')}px / {slide.get('clientHeight','?')}px — {slide_status}</p>
<table>
<tr><th>zone</th><th>scrollHeight</th><th>clientHeight</th><th>상태</th><th>블록 상세</th></tr>
"""
for zn, zd in zones.items():
z_status = '<span class="ok">OK</span>' if not zd.get("overflowed") else f'<span class="warn">+{zd.get("excess_px",0)}px</span>'
block_details = ", ".join(
f'{bl.get("block_type","?")}:{bl.get("scrollHeight","?")}px'
for bl in zd.get("blocks", [])
)
html += f'<tr><td>{zn}</td><td>{zd.get("scrollHeight","")}</td><td>{zd.get("clientHeight","")}</td><td>{z_status}</td><td style="font-size:12px">{block_details}</td></tr>\n'
html += "</table>\n"
html += "</div>\n"
html += '<div class="arrow">▼</div>\n'
# ── Step 5 ──
if step5:
needs = step5.get("needs_adjustment", False)
issues = step5.get("issues", [])
adjs = step5.get("adjustments", [])
html += f"""
<div class="step">
<div class="step-header">
<span class="step-badge">Kei 실장</span>
<span class="step-title">Step 5: 최종 검수</span>
</div>
<div class="step-desc">렌더링 결과를 Kei가 검수. overflow 없으면 skip.</div>
<p><strong>조정 필요:</strong> {'<span class="warn">예</span>' if needs else '<span class="ok">아니오</span>'}</p>
"""
if issues:
html += '<h4>이슈:</h4><ul>\n'
for iss in issues:
html += f'<li>{iss}</li>\n'
html += '</ul>\n'
if adjs:
html += '<h4>조정 사항:</h4>\n<table><tr><th>area</th><th>action</th><th>detail</th></tr>\n'
for adj in adjs:
html += f'<tr><td>{adj.get("block_area","")}</td><td>{adj.get("action","")}</td><td>{adj.get("detail","")[:100]}</td></tr>\n'
html += '</table>\n'
html += "</div>\n"
else:
html += """
<div class="step">
<div class="step-header">
<span class="step-badge">Kei 실장</span>
<span class="step-title">Step 5: 최종 검수</span>
</div>
<div class="step-desc"><span class="ok">Skip — overflow 없음.</span></div>
</div>
"""
html += '<div class="arrow">▼</div>\n'
# ── Final ──
if final_html:
# iframe으로 최종 결과물 미리보기
import html as html_lib
escaped = html_lib.escape(final_html)
html += f"""
<div class="step">
<div class="step-header">
<span class="step-badge code">최종 결과</span>
<span class="step-title">완성 슬라이드</span>
</div>
<div class="final-preview">
<iframe srcdoc="{escaped}" style="transform:scale(0.85); width:1280px; height:720px;"></iframe>
</div>
</div>
"""
html += """
</div>
</body>
</html>"""
return html
def _ts_to_str(run_id: str) -> str:
try:
from datetime import datetime
ts = int(run_id) / 1000
return datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M:%S")
except Exception:
return run_id
def main():
if len(sys.argv) > 1:
run_id = sys.argv[1]
else:
# 최신 run 자동 선택
runs = sorted(RUNS_DIR.iterdir(), key=lambda p: p.name, reverse=True)
if not runs:
print("data/runs/ 에 실행 결과가 없습니다.")
sys.exit(1)
run_id = runs[0].name
print(f"리포트 생성: run={run_id}")
report = generate_report(run_id)
output_path = RUNS_DIR / run_id / "report.html"
output_path.write_text(report, encoding="utf-8")
print(f"저장: {output_path}")
print(f"브라우저에서 열기: file:///{output_path}")
if __name__ == "__main__":
main()