- 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>
388 lines
16 KiB
Python
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()
|