Phase W + V' 완료: before→filled→after 파이프라인 + 조립 로직 수정

Phase W:
- weight 비율 초기 배정 (space_allocator header 높이 반영)
- block_assembler 공통 조립 함수 (filled/assembled 통합)
- filled → Selenium 측정 → context 저장
- sidebar overflow 확장 + body 재배분
- sub_layouts 사전 계산 (이미지 누락 해결)

Phase V':
- 팝업 링크 우측상단 배치 (인라인 → position:absolute)
- 표 내용 Kei 판단 (공란 크기 계산 → 행/열 산출 → Kei 요약)
- 출처 라벨 삭제 + 이미지 아래 캡션 배치
- after 공란 제거 (결론 바로 위까지 body/sidebar 채움)

추가:
- V-10 bold 키워드: 기계적 추출 → Kei 문맥 판단
- ** 마크다운 → <strong> 변환
- [이미지:] 마커 제거 (bold 변환 전 처리)
- grid-template-rows AFTER 크기 반영 (Sonnet final)
- assemble_stage2 CSS font-size override, white-space fix
- 하드코딩 전수 검토 완료
- 본심 여러 topic 텍스트 합침

Phase X 계획 문서 작성 (동적 역할 구조)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-06 05:00:52 +09:00
parent 24eb1bc5ad
commit 1f7579cf64
64 changed files with 13955 additions and 696 deletions

View File

@@ -0,0 +1,235 @@
"""Phase T 전체 파이프라인 시뮬레이션 (Stage 0 ~ Stage 5).
API 호출을 mock으로 대체하여 코드 경로 전체를 검증.
실제 Kei 응답(기존 run) + mock Sonnet/Selenium으로 전 Stage 통과 여부 확인.
"""
import asyncio
import json
import sys
import logging
from pathlib import Path
from unittest.mock import patch, AsyncMock, MagicMock
sys.path.insert(0, ".")
logging.basicConfig(level=logging.WARNING)
# ── 실제 데이터 로드 ──
RUN_DIR = Path("data/runs/1774922951020")
STAGE_0_DIR = Path("data/runs/20260401_151426")
stage0_ctx = json.loads((STAGE_0_DIR / "stage_0_context.json").read_text(encoding="utf-8"))
raw_content = stage0_ctx["raw_content"]
analysis_1a = json.loads((RUN_DIR / "step1_analysis.json").read_text(encoding="utf-8"))
concepts_1b = json.loads((RUN_DIR / "step1b_concepts.json").read_text(encoding="utf-8"))
# ── Mock 응답 정의 ──
async def mock_classify_content(content):
"""Stage 1A mock: 실제 Kei 응답 반환"""
return analysis_1a
async def mock_refine_concepts(content, analysis):
"""Stage 1B mock: 실제 Kei 1B 응답을 analysis에 병합하여 반환"""
result = dict(analysis)
concepts = concepts_1b.get("concepts", [])
for t in result.get("topics", []):
match = next((c for c in concepts if c.get("id") == t.get("id")), None)
if match:
t["relation_type"] = match.get("relation_type", "")
t["expression_hint"] = match.get("expression_hint", "")
t["source_data"] = match.get("source_data", "")
return result
# Stage 2 mock: generate_with_retry → mock HTML 반환
MOCK_BODY_HTML = """<div style="overflow:hidden; font-size:12px;">
<div class="bg" style="padding:10px;">
<h3 style="font-size:11px;">용어 혼용</h3>
<p style="font-size:11px;">DX와 BIM이 혼용되어 사용되고 있다</p>
</div>
<div style="height:12px;"></div>
<div class="core" style="padding:10px;">
<h3 style="font-size:12px;">DX와 핵심기술의 올바른 관계</h3>
<p style="font-size:12px;">DX는 BIM, GIS, 디지털트윈을 포함하는 상위개념이다</p>
<div class="key-msg" style="font-size:14px; font-weight:bold;">BIM ≠ DX</div>
</div>
</div>"""
MOCK_SIDEBAR_HTML = """<div style="overflow:hidden; font-size:10px; padding-left:14px; text-indent:-14px;">
<h3 style="font-size:10px;">용어 정의</h3>
<div style="padding-left:14px; text-indent:-14px;">
<p>건설산업: 종합산업</p>
<p>BIM: 정보관리도구</p>
<p>DX: 디지털 전환</p>
</div>
</div>"""
MOCK_FOOTER_HTML = """<div style="background:linear-gradient(135deg,#1e40af,#3b82f6); padding:14px 30px; text-align:center; border-radius:6px;">
<span style="font-size:14px; font-weight:bold; color:white;">BIM은 DX의 기초가 되는 일부분이다</span>
</div>"""
MOCK_GENERATED = {
"body_html": MOCK_BODY_HTML,
"sidebar_html": MOCK_SIDEBAR_HTML,
"footer_html": MOCK_FOOTER_HTML,
"reasoning": "mock",
}
MOCK_VERIFICATION = {} # verify_all_areas 결과
async def mock_generate_with_retry(content, analysis, container_specs, preset, images=None):
"""Stage 2 mock"""
from src.content_verifier import VerificationResult
verification = {
"body_bg": VerificationResult(passed=True, area_name="body_bg", score=1.0),
"body_core": VerificationResult(passed=True, area_name="body_core", score=1.0),
"sidebar": VerificationResult(passed=True, area_name="sidebar", score=1.0),
"footer": VerificationResult(passed=True, area_name="footer", score=1.0),
}
return MOCK_GENERATED, verification
def mock_render_slide_from_html(generated, analysis, preset):
"""Stage 3 mock"""
return f"""<!DOCTYPE html>
<html lang="ko"><head><meta charset="UTF-8">
<style>.slide {{width:1280px;height:720px;padding:40px;}}</style>
</head><body><div class="slide">
<div class="area-header"><h1>{analysis.get('title','')}</h1></div>
<div class="area-body">{generated.get('body_html','')}</div>
<div class="area-sidebar">{generated.get('sidebar_html','')}</div>
<div class="area-footer">{generated.get('footer_html','')}</div>
</div></body></html>"""
def mock_measure_rendered_heights(html):
"""Stage 4 L4 mock: overflow 없음"""
return {
"zones": {
"body": {"scrollHeight": 400, "clientHeight": 490, "overflowed": False},
"sidebar": {"scrollHeight": 300, "clientHeight": 490, "overflowed": False},
"footer": {"scrollHeight": 55, "clientHeight": 60, "overflowed": False},
}
}
def mock_capture_slide_screenshot(html):
"""Stage 4 L5 mock: 빈 스크린샷"""
return "" # 빈 문자열 → 비전 품질 게이트 스킵
async def run_full_simulation():
"""전체 파이프라인 시뮬레이션"""
passed = 0
failed = 0
def check(name, condition, detail=""):
nonlocal passed, failed
if condition:
print(f"{name}")
passed += 1
else:
print(f"{name}")
if detail:
print(f"{detail}")
failed += 1
# Mock 패치 적용
# _retry_kei는 async fn을 await하는 래퍼이므로, mock도 await 해야 함
async def mock_retry_kei(fn, *a, **kw):
return await fn(*a, **kw)
with patch("src.pipeline._retry_kei", side_effect=mock_retry_kei), \
patch("src.kei_client.classify_content", side_effect=mock_classify_content), \
patch("src.kei_client.refine_concepts", side_effect=mock_refine_concepts), \
patch("src.content_verifier.generate_with_retry", side_effect=mock_generate_with_retry), \
patch("src.renderer.render_slide_from_html", side_effect=mock_render_slide_from_html), \
patch("src.slide_measurer.measure_rendered_heights", side_effect=mock_measure_rendered_heights), \
patch("src.slide_measurer.capture_slide_screenshot", side_effect=mock_capture_slide_screenshot), \
patch("src.image_utils.get_image_sizes", return_value={}), \
patch("src.image_utils.embed_images", side_effect=lambda html, bp: html):
from src.pipeline import generate_slide
events = []
print("── 전체 파이프라인 실행 ──")
try:
async for event in generate_slide(raw_content):
events.append(event)
evt_type = event.get("event", "")
evt_data = event.get("data", "")
if evt_type == "progress":
print(f" 📌 {evt_data}")
elif evt_type == "error":
print(f" ❌ ERROR: {evt_data}")
elif evt_type == "result":
print(f" 📄 result: {len(evt_data)}자 HTML")
except Exception as e:
print(f" 💥 EXCEPTION: {type(e).__name__}: {e}")
import traceback
traceback.print_exc()
check("파이프라인 예외 없음", False, str(e))
print(f"\n{'' * 55}")
print(f" 전체 시뮬레이션: {passed} passed, {failed} failed")
print(f"{'' * 55}")
return False
print()
# 이벤트 검증
event_types = [e["event"] for e in events]
check("progress 이벤트 존재", "progress" in event_types)
check("error 이벤트 없음", "error" not in event_types,
f"errors: {[e['data'] for e in events if e['event']=='error']}")
check("result 이벤트 존재", "result" in event_types)
# result HTML 검증
result_events = [e for e in events if e["event"] == "result"]
if result_events:
html = result_events[0]["data"]
check("HTML 비어있지 않음", len(html) > 100, f"길이: {len(html)}")
check("HTML에 slide 클래스", "slide" in html)
check("HTML에 body 영역", "area-body" in html or "body_html" in html or "bg" in html)
check("HTML에 sidebar 영역", "area-sidebar" in html or "sidebar" in html)
check("HTML에 footer 영역", "area-footer" in html or "footer" in html)
else:
check("result HTML", False, "result 이벤트 없음")
# 스냅샷 파일 확인
import glob
latest_runs = sorted(glob.glob("data/runs/2026*"), reverse=True)
if latest_runs:
run_dir = latest_runs[0]
files = [Path(f).name for f in glob.glob(f"{run_dir}/*.json")]
print(f"\n 스냅샷 폴더: {Path(run_dir).name}")
print(f" 저장된 파일: {files}")
check("stage_0 스냅샷", "stage_0_context.json" in files)
check("stage_1a 스냅샷", "stage_1a_context.json" in files)
check("stage_1b 스냅샷", "stage_1b_context.json" in files)
check("stage_1_5a 스냅샷", "stage_1_5a_context.json" in files)
check("stage_1_7 스냅샷", "stage_1_7_context.json" in files)
check("stage_1_5b 스냅샷", "stage_1_5b_context.json" in files)
check("stage_2 스냅샷", "stage_2_context.json" in files)
check("stage_3 스냅샷", "stage_3_context.json" in files)
check("stage_4 스냅샷", "stage_4_context.json" in files)
check("final 스냅샷", "final_context.json" in files)
check("final.html 저장", "final.html" in [Path(f).name for f in glob.glob(f"{run_dir}/*")])
else:
check("스냅샷 폴더", False, "run 폴더 없음")
print(f"\n{'' * 55}")
print(f" 전체 파이프라인 시뮬레이션: {passed} passed, {failed} failed")
if failed == 0:
print(" 전체 통과 ✅")
else:
print(f"{failed}개 실패")
print(f"{'' * 55}")
return failed == 0
if __name__ == "__main__":
success = asyncio.run(run_full_simulation())
sys.exit(0 if success else 1)