Phase S: Claude HTML 직접 생성 + 독립 검증 시스템 도입

블록 선택 방식(Phase P/Q/R) 폐기 → Claude Sonnet이 영역별 HTML 직접 생성.
생성-검증 분리: content_verifier.py로 텍스트 보존/금지 콘텐츠/구조를 코드 검증.

주요 변경:
- src/html_generator.py: 4개 프롬프트 템플릿(BG/CORE/SIDEBAR/FOOTER) + 영역별 Claude 호출
- src/content_verifier.py: L1 텍스트 보존, L2 금지 콘텐츠, L3 구조 검증 + 재시도 루프
- src/html_validator.py: 보안 검증(script/iframe 제거)
- src/renderer.py: render_slide_from_html() 추가, area div overflow:hidden
- scripts/test_phase_s.py: generate_with_retry() 통합, step2b_verification 결과 저장
- 배경 라이트 디자인(#f8fafc), 개조식 어미 변환, 축약 금지 규칙

다음 과제: 폰트 위계(핵심14>본문12>배경10-12>첨부9-11) + 동적 컨테이너 계산

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-31 08:37:05 +09:00
parent 9410576e60
commit 0e4b8c091c
14 changed files with 3875 additions and 242 deletions

View File

@@ -62,6 +62,41 @@ def _load_catalog_map() -> dict[str, str]:
return _CATALOG_MAP
# Phase R: variant별 template 경로 캐시
_CATALOG_VARIANT_MAP: dict[str, str] | None = None
def _load_catalog_map_with_variants() -> dict[str, str]:
"""catalog.yaml에서 variant별 template 경로 매핑을 로드한다.
키: "block_id--variant_id" → 값: template 경로
"""
global _CATALOG_VARIANT_MAP
# _load_catalog_map이 이미 캐시 관리하므로 같은 mtime 사용
_load_catalog_map() # 캐시 갱신 보장
if _CATALOG_VARIANT_MAP is not None and _CATALOG_MTIME == (CATALOG_PATH.stat().st_mtime if CATALOG_PATH.exists() else 0.0):
return _CATALOG_VARIANT_MAP
_CATALOG_VARIANT_MAP = {}
if CATALOG_PATH.exists():
try:
with open(CATALOG_PATH, encoding="utf-8") as f:
catalog = yaml.safe_load(f)
for block in catalog.get("blocks", []):
block_id = block.get("id", "")
for variant in block.get("variants", []):
vid = variant.get("id", "default")
vtemplate = variant.get("template", "")
if vid != "default" and vtemplate:
_CATALOG_VARIANT_MAP[f"{block_id}--{vid}"] = vtemplate
except Exception as e:
logger.warning(f"catalog variant 로드 실패: {e}")
return _CATALOG_VARIANT_MAP
def create_jinja_env() -> Environment:
"""Jinja2 환경 생성."""
return Environment(
@@ -70,19 +105,33 @@ def create_jinja_env() -> Environment:
)
def _resolve_template_path(env: Environment, block_type: str) -> str | None:
"""블록 타입로 템플릿 경로를 찾는다.
def _resolve_template_path(env: Environment, block_type: str, variant: str = "default") -> str | None:
"""블록 타입 + variant로 템플릿 경로를 찾는다.
Phase R: variant가 지정되면 variant 전용 템플릿을 우선 탐색.
variant 템플릿이 없으면 기존 블록 템플릿으로 fallback.
검색 순서:
0. catalog.yaml 매핑 (id → template 경로, 최우선)
1. 정확한 경로 (blocks/cards/card-icon-desc.html 등 — 팀장이 카테고리 포함 지정)
0-v. catalog.yaml의 variant별 template 경로 (Phase R, 최우선)
0. catalog.yaml 매핑 (id → template 경로)
1. 정확한 경로 (blocks/cards/card-icon-desc.html 등)
2. 카테고리 폴더 검색 (blocks/{category}/{block_type}.html)
3. _legacy fallback (blocks/_legacy/{block_type}.html)
4. 루트 fallback (blocks/{block_type}.html)
"""
candidates = []
# 0. catalog.yaml에서 id → template 매핑 조회 (최우선)
# 0-v. Phase R: variant 전용 템플릿 우선 탐색
if variant and variant != "default":
catalog_map_full = _load_catalog_map_with_variants()
variant_key = f"{block_type}--{variant}"
if variant_key in catalog_map_full:
candidates.append(catalog_map_full[variant_key])
# 카테고리 폴더에서 variant 파일 탐색
for category in BLOCK_CATEGORIES:
candidates.append(f"blocks/{category}/{block_type}--{variant}.html")
# 0. catalog.yaml에서 id → template 매핑 조회
catalog_map = _load_catalog_map()
if block_type in catalog_map:
catalog_path = catalog_map[block_type]
@@ -272,7 +321,7 @@ def render_multi_page(layout_concept: dict[str, Any]) -> str:
block_data = _preprocess_svg_data(block_type, block_data)
# DA-21: 카테고리 경로 검색
template_path = _resolve_template_path(env, block_type)
template_path = _resolve_template_path(env, block_type, block.get("_variant", "default") if isinstance(block, dict) else "default")
if template_path:
try:
@@ -352,7 +401,7 @@ def render_slide(layout: dict[str, Any]) -> str:
block_type = block["type"]
block_data = block.get("data", {})
template_path = _resolve_template_path(env, block_type)
template_path = _resolve_template_path(env, block_type, block.get("_variant", "default") if isinstance(block, dict) else "default")
if template_path:
try:
@@ -403,10 +452,154 @@ def render_slide(layout: dict[str, Any]) -> str:
return html
def render_standalone_block(block_type: str, data: dict[str, Any]) -> str:
def render_block_in_container(
block_type: str,
data: dict[str, Any],
container_height_px: int,
container_width_px: int,
font_size_px: float = 15.2,
padding_px: int = 20,
) -> str:
"""Phase P: 단일 블록을 컨테이너 안에서 렌더링한다.
컨테이너 크기에 맞게 CSS가 적용된 완전한 HTML을 반환.
Selenium으로 스크린샷 캡처 + 높이 측정에 사용.
"""
block_html = render_standalone_block(block_type, data)
tokens_css = (STATIC_DIR / "tokens.css").read_text(encoding="utf-8")
base_css = (STATIC_DIR / "base.css").read_text(encoding="utf-8")
base_css = base_css.replace("@import url('./tokens.css');", "")
return f"""<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<style>
{tokens_css}
{base_css}
.candidate-container {{
width: {container_width_px}px;
height: {container_height_px}px;
overflow: visible;
font-size: {font_size_px}px;
--font-body: {font_size_px / 16:.3f}rem;
--spacing-inner: {padding_px}px;
padding: {padding_px}px;
font-family: 'Pretendard Variable', 'Pretendard', sans-serif;
line-height: 1.7;
word-break: keep-all;
}}
</style>
</head>
<body>
<div class="candidate-container">
{block_html}
</div>
</body>
</html>"""
def render_slide_from_html(
generated: dict[str, str],
analysis: dict[str, Any],
preset: dict[str, Any],
) -> str:
"""Phase R': AI가 생성한 HTML 조각을 슬라이드 프레임에 삽입하여 완성 HTML 반환.
블록 템플릿을 렌더링하지 않는다. AI가 생성한 body/sidebar/footer HTML을 직접 삽입.
Args:
generated: {"body_html": "...", "sidebar_html": "...", "footer_html": "..."}
analysis: 1단계 분석 결과 (title)
preset: 프리셋 정보 (grid_areas, grid_columns, grid_rows)
"""
tokens_css = ""
tokens_path = Path(__file__).parent.parent / "static" / "tokens.css"
if tokens_path.exists():
tokens_css = tokens_path.read_text(encoding="utf-8")
base_css = ""
base_path = Path(__file__).parent.parent / "static" / "base.css"
if base_path.exists():
# @import 제거 (inline으로 포함하므로)
raw = base_path.read_text(encoding="utf-8")
base_css = "\n".join(
line for line in raw.split("\n")
if not line.strip().startswith("@import")
)
title = analysis.get("title", "슬라이드")
grid_areas = preset.get("grid_areas", "'header header' 'body sidebar' 'footer footer'")
grid_columns = preset.get("grid_columns", "65fr 35fr")
grid_rows = preset.get("grid_rows", "auto 1fr auto")
body_html = generated.get("body_html", "")
sidebar_html = generated.get("sidebar_html", "")
footer_html = generated.get("footer_html", "")
html = f"""<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>{title}</title>
<style>
{tokens_css}
{base_css}
.slide-1 {{
grid-template-areas: {grid_areas};
grid-template-columns: {grid_columns};
grid-template-rows: {grid_rows};
}}
.slide-1 .area-body {{ grid-area: body; }}
.slide-1 .area-sidebar {{ grid-area: sidebar; }}
.slide-1 .area-footer {{ grid-area: footer; }}
.slide + .slide {{ margin-top: 40px; }}
@media print {{
.slide {{ page-break-after: always; }}
.slide + .slide {{ margin-top: 0; }}
}}
</style>
</head>
<body>
<div class="slide slide-1">
<div class="slide-title" style="grid-area: header;">{title}</div>
<div class="area-body" style="overflow:hidden;">
{body_html}
</div>
<div class="area-sidebar" style="overflow:hidden;">
{sidebar_html}
</div>
<div class="area-footer" style="overflow:hidden;">
{footer_html}
</div>
</div>
<script>
window.onbeforeprint = function() {{
document.querySelectorAll('details').forEach(function(d) {{ d.open = true; }});
}};
window.onafterprint = function() {{
document.querySelectorAll('details').forEach(function(d) {{ d.open = false; }});
}};
</script>
</body>
</html>"""
logger.info(f"[R'] 슬라이드 렌더링 완료: {title}")
return html
def render_standalone_block(block_type: str, data: dict[str, Any], variant: str = "default") -> str:
"""단일 블록을 독립 HTML로 렌더링 (테스트/미리보기용)."""
env = create_jinja_env()
template_path = _resolve_template_path(env, block_type)
template_path = _resolve_template_path(env, block_type, variant)
if not template_path:
return f"<div>블록 미발견: {block_type}</div>"
template = env.get_template(template_path)