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>
This commit is contained in:
387
scripts/generate_run_report.py
Normal file
387
scripts/generate_run_report.py
Normal file
@@ -0,0 +1,387 @@
|
||||
"""파이프라인 실행 리포트 생성기.
|
||||
|
||||
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()
|
||||
Reference in New Issue
Block a user