"""파이프라인 실행 리포트 생성기.
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"
Design Agent 파이프라인 리포트
Run ID: {run_id} | 생성: {_ts_to_str(run_id)}
"""
# ── Step 1A ──
if step1:
topics = step1.get("topics", [])
page_struct = step1.get("page_structure", {})
html += f"""
원본 콘텐츠를 분석하여 핵심 메시지, 꼭지 구조, 페이지 비중을 설계한다.
| 항목 | 값 |
| 제목 | {step1.get('title','')} |
| 핵심 메시지 | {step1.get('core_message','')} |
| 정보 구조 | {step1.get('info_structure','')[:200]} |
페이지 구조 (비중)
| 역할 | topic_ids | 비중(weight) | 시각화 |
"""
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'| {role} | {tids} | {w:.0%} | '
html += f' |
\n'
html += "
\n"
html += """
꼭지 목록
| # | 제목 | purpose | role | layer | section_title |
"""
for t in topics:
st = t.get("section_title", "")
html += f"""
| {t.get('id','')} |
{t.get('title','')} |
{t.get('purpose','')} |
{t.get('role','')} |
{t.get('layer','')} |
{st if st else '-'} |
\n"""
html += "
\n"
html += '
▼
\n'
# ── Step 1B ──
if step1b:
concepts = step1b.get("concepts", [])
html += f"""
각 꼭지의 관계 성격(relation_type), 표현 힌트(expression_hint), 원본 데이터를 구체화한다.
| # | 제목 | relation_type | expression_hint | source_data |
"""
for c in concepts:
tid = c.get("topic_id") or c.get("id", "?")
html += f"""
| {tid} |
{c.get('title','')} |
{c.get('relation_type','')} |
{c.get('expression_hint','')[:120]} |
{c.get('source_data','')[:120]} |
\n"""
html += "
\n"
html += '
▼
\n'
# ── Step 2 ──
if step2:
blocks = step2.get("blocks", [])
overflows = step2.get("overflow", [])
html += f"""
Step A: 규칙 기반 프리셋 선택
Step A-2 (Kei): 각 꼭지에 적합한 블록 확정 (코드 레벨 강제)
Step B (Sonnet): zone 배치 + char_guide만 결정 (블록 타입 변경 불가)
프리셋: {step2.get('preset','')}
| area | 블록 타입 | purpose | topic | 이유 | 크기 |
"""
for b in blocks:
html += f"""
| {b.get('area','')} |
{b.get('type','')} |
{b.get('purpose','')} |
{b.get('topic_id','')} |
{b.get('reason','')[:100]} |
{b.get('size','')} |
\n"""
html += "
\n"
if overflows:
html += '
높이 초과 예상
\n
\n'
html += '| zone | 예산(px) | 합계(px) | 초과(px) |
\n'
for o in overflows:
html += f'| {o.get("area","")} | {o.get("budget_px","")} | {o.get("total_px","")} | +{o.get("overflow_px","")} |
\n'
html += "
\n"
html += "
\n"
html += '
▼
\n'
# ── Step 2B (Allocation) ──
if step2b:
html += f"""
Kei의 비중(weight)을 기반으로 각 zone 내 블록별 max_height_px와 max_chars를 수학적으로 계산한다.
{json.dumps(step2b, ensure_ascii=False, indent=2)}
"""
html += '
▼
\n'
# ── Step 3 ──
if step3:
filled = step3.get("blocks", [])
html += f"""
원본 콘텐츠에서 각 블록의 슬롯에 맞는 텍스트를 추출/편집한다. 원본 보존 원칙.
| area | 블록 타입 | topic | 글자 수 | 데이터 (요약) |
"""
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"""
| {b.get('area','')} |
{b.get('type','')} |
{b.get('topic_id','')} |
{b.get('char_count','')} |
{preview} |
\n"""
html += "
\n"
html += '
▼
\n'
# ── Step 4 (CSS + Measurement) ──
html += f"""
텍스트 양에 맞게 CSS 변수(폰트, 여백)를 조정하고 Jinja2로 HTML을 조립한다.
"""
if step4_css:
html += f'
{json.dumps(step4_css, ensure_ascii=False, indent=2)}
\n'
if step4_measure:
slide = step4_measure.get("slide", {})
zones = step4_measure.get("zones", {})
slide_status = '
OK' if not slide.get("overflowed") else f'
+{slide.get("excess_px",0)}px 초과'
html += f"""
Phase L: Selenium 렌더링 측정
슬라이드 전체: {slide.get('scrollHeight','?')}px / {slide.get('clientHeight','?')}px — {slide_status}
| zone | scrollHeight | clientHeight | 상태 | 블록 상세 |
"""
for zn, zd in zones.items():
z_status = 'OK' if not zd.get("overflowed") else f'+{zd.get("excess_px",0)}px'
block_details = ", ".join(
f'{bl.get("block_type","?")}:{bl.get("scrollHeight","?")}px'
for bl in zd.get("blocks", [])
)
html += f'| {zn} | {zd.get("scrollHeight","")} | {zd.get("clientHeight","")} | {z_status} | {block_details} |
\n'
html += "
\n"
html += "
\n"
html += '
▼
\n'
# ── Step 5 ──
if step5:
needs = step5.get("needs_adjustment", False)
issues = step5.get("issues", [])
adjs = step5.get("adjustments", [])
html += f"""
렌더링 결과를 Kei가 검수. overflow 없으면 skip.
조정 필요: {'예' if needs else '아니오'}
"""
if issues:
html += '
이슈:
\n'
for iss in issues:
html += f'- {iss}
\n'
html += '
\n'
if adjs:
html += '
조정 사항:
\n
| area | action | detail |
\n'
for adj in adjs:
html += f'| {adj.get("block_area","")} | {adj.get("action","")} | {adj.get("detail","")[:100]} |
\n'
html += '
\n'
html += "
\n"
else:
html += """
"""
html += '
▼
\n'
# ── Final ──
if final_html:
# iframe으로 최종 결과물 미리보기
import html as html_lib
escaped = html_lib.escape(final_html)
html += f"""
"""
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()