"""파이프라인 실행 리포트 생성기. 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"

Run not found: {run_id}

" # 데이터 로드 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""" 파이프라인 리포트 — Run {run_id}

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"""
Kei 실장 Step 1A: 꼭지 추출 + 스토리라인 설계
원본 콘텐츠를 분석하여 핵심 메시지, 꼭지 구조, 페이지 비중을 설계한다.
항목
제목{step1.get('title','')}
핵심 메시지{step1.get('core_message','')}
정보 구조{step1.get('info_structure','')[:200]}

페이지 구조 (비중)

""" 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'' html += f'\n' html += "
역할topic_ids비중(weight)시각화
{role}{tids}{w:.0%}
\n" html += """

꼭지 목록

""" for t in topics: st = t.get("section_title", "") html += f"""\n""" html += "
#제목purposerolelayersection_title
{t.get('id','')} {t.get('title','')} {t.get('purpose','')} {t.get('role','')} {t.get('layer','')} {st if st else '-'}
\n" html += '
\n' # ── Step 1B ── if step1b: concepts = step1b.get("concepts", []) html += f"""
Kei 실장 Step 1B: 컨셉 구체화
각 꼭지의 관계 성격(relation_type), 표현 힌트(expression_hint), 원본 데이터를 구체화한다.
""" for c in concepts: tid = c.get("topic_id") or c.get("id", "?") html += f"""\n""" html += "
#제목relation_typeexpression_hintsource_data
{tid} {c.get('title','')} {c.get('relation_type','')} {c.get('expression_hint','')[:120]} {c.get('source_data','')[:120]}
\n" html += '
\n' # ── Step 2 ── if step2: blocks = step2.get("blocks", []) overflows = step2.get("overflow", []) html += f"""
Kei 실장 Step 2 (A-2 + B): 블록 배치
Step A: 규칙 기반 프리셋 선택
Step A-2 (Kei): 각 꼭지에 적합한 블록 확정 (코드 레벨 강제)
Step B (Sonnet): zone 배치 + char_guide만 결정 (블록 타입 변경 불가)

프리셋: {step2.get('preset','')}

""" for b in blocks: html += f"""\n""" html += "
area블록 타입purposetopic이유크기
{b.get('area','')} {b.get('type','')} {b.get('purpose','')} {b.get('topic_id','')} {b.get('reason','')[:100]} {b.get('size','')}
\n" if overflows: html += '

높이 초과 예상

\n\n' html += '\n' for o in overflows: html += f'\n' html += "
zone예산(px)합계(px)초과(px)
{o.get("area","")}{o.get("budget_px","")}{o.get("total_px","")}+{o.get("overflow_px","")}
\n" html += "
\n" html += '
\n' # ── Step 2B (Allocation) ── if step2b: html += f"""
코드 (결정론적) Step 2B: 공간 할당
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"""
Kei 편집자 Step 3: 텍스트 편집
원본 콘텐츠에서 각 블록의 슬롯에 맞는 텍스트를 추출/편집한다. 원본 보존 원칙.
""" 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"""\n""" html += "
area블록 타입topic글자 수데이터 (요약)
{b.get('area','')} {b.get('type','')} {b.get('topic_id','')} {b.get('char_count','')} {preview}
\n" html += '
\n' # ── Step 4 (CSS + Measurement) ── html += f"""
Sonnet 실무자 Step 4: CSS 조정 + 렌더링
텍스트 양에 맞게 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}

""" 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'\n' html += "
zonescrollHeightclientHeight상태블록 상세
{zn}{zd.get("scrollHeight","")}{zd.get("clientHeight","")}{z_status}{block_details}
\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 실장 Step 5: 최종 검수
렌더링 결과를 Kei가 검수. overflow 없으면 skip.

조정 필요: {'' if needs else '아니오'}

""" if issues: html += '

이슈:

\n' if adjs: html += '

조정 사항:

\n\n' for adj in adjs: html += f'\n' html += '
areaactiondetail
{adj.get("block_area","")}{adj.get("action","")}{adj.get("detail","")[:100]}
\n' html += "
\n" else: html += """
Kei 실장 Step 5: 최종 검수
Skip — overflow 없음.
""" 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()