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:
@@ -1,11 +1,13 @@
|
||||
"""Phase O: 컨테이너 기반 공간 할당 시스템.
|
||||
"""Phase O + Phase Q: 컨테이너 기반 공간 할당 + 글자수 예산 + 글루 모델.
|
||||
|
||||
Kei 비중 → 컨테이너 px 확정 → 블록 제약 계산 → 편집자 스펙 생성.
|
||||
LLM 추정이 아닌 결정론적 계산.
|
||||
|
||||
주요 함수:
|
||||
- calculate_container_specs(): Kei 비중 → 역할별 ContainerSpec
|
||||
- finalize_block_specs(): 컨테이너 크기 → 블록별 내부 스펙
|
||||
- calculate_container_specs(): Kei 비중 → 역할별 ContainerSpec (Phase O)
|
||||
- finalize_block_specs(): 컨테이너 크기 → 블록별 내부 스펙 (Phase O)
|
||||
- calculate_char_budget(): 블록+컨테이너 → 글자수 예산 사전 계산 (Phase Q-3)
|
||||
- apply_glue_compression(): overflow 시 수학적 간격 축소 (Phase Q-7)
|
||||
- calculate_trim_chars(): 초과 px → 삭제 글자 수
|
||||
"""
|
||||
from __future__ import annotations
|
||||
@@ -310,3 +312,247 @@ def calculate_trim_chars(
|
||||
lines_to_remove = math.ceil(excess_px / line_height_px)
|
||||
chars_per_line = int(container_width_px / avg_char_width_px)
|
||||
return max(lines_to_remove * chars_per_line, 10)
|
||||
|
||||
|
||||
# ──────────────────────────────────────
|
||||
# Phase Q-3: 글자수 예산 계산
|
||||
# ──────────────────────────────────────
|
||||
|
||||
# 블록 유형별 구조적 오버헤드 (제목, 패딩, 간격 등 — px 단위)
|
||||
# Phase Q 2차 테스트 기반 실측 보정: 실제 CSS padding/margin 기반
|
||||
_BLOCK_STRUCTURAL_OVERHEAD: dict[str, int] = {
|
||||
"card-numbered": 40, # 패딩 12*2=24 + gap 10 + border 2 + 여유
|
||||
"card-icon-desc": 50, # 아이콘 40 + 패딩 + gap
|
||||
"card-step-vertical": 50, # 마커 30 + 패딩 + gap
|
||||
"dark-bullet-list": 52, # 패딩 20*2=40 + 제목 줄 12
|
||||
"comparison-2col": 60, # 헤더*2 + 구분선 + 패딩
|
||||
"compare-3col-badge": 60, # 헤더 행 40 + 배지 + 패딩
|
||||
"compare-2col-split": 60, # 헤더 행 40 + 패딩
|
||||
"table-simple-striped": 50, # 헤더 행 35 + 패딩
|
||||
"banner-gradient": 36, # 패딩 16*2=32 + 여유
|
||||
"callout-solution": 50, # 아이콘 + 제목 30 + 패딩 20
|
||||
"callout-warning": 50, # 아이콘 + 제목 30 + 패딩 20
|
||||
"quote-big-mark": 50, # 따옴표 장식 + 패딩 20*2
|
||||
"quote-question": 76, # 패딩 28*2=56 + desc margin 10 + 여유 10 (실측 기반)
|
||||
"compare-pill-pair": 52, # 외곽 패딩 6*2 + 내부 패딩 18*2 + 여유
|
||||
"venn-diagram": 60, # SVG 구조 + 패딩
|
||||
"process-horizontal": 50, # 화살표 + 번호 36 + 패딩
|
||||
"flow-arrow-horizontal": 30, # 캡슐 + 화살표 + 패딩
|
||||
"keyword-circle-row": 60, # 원형 + 라벨 + 패딩
|
||||
}
|
||||
|
||||
# 같은 컨테이너 내 블록 간 gap (px)
|
||||
_CONTAINER_BLOCK_GAP = 8
|
||||
|
||||
|
||||
def calculate_char_budget(
|
||||
block_type: str,
|
||||
container_spec: ContainerSpec,
|
||||
block_def: dict | None = None,
|
||||
) -> dict:
|
||||
"""블록이 컨테이너에서 수용 가능한 최대 글자수를 사전 계산한다.
|
||||
|
||||
Phase Q 핵심: 이 예산이 AI 콘텐츠 생성의 하드 제약.
|
||||
|
||||
Args:
|
||||
block_type: 블록 ID (예: "venn-diagram")
|
||||
container_spec: 이 topic이 속한 컨테이너
|
||||
block_def: catalog.yaml의 블록 정의 (None이면 기본값 사용)
|
||||
|
||||
Returns:
|
||||
{
|
||||
"total_chars": int, # 전체 글자수 예산
|
||||
"max_items": int, # 최대 항목 수
|
||||
"chars_per_item": int, # 항목당 최대 글자수
|
||||
"font_size_px": float, # 적용 폰트 크기
|
||||
"available_lines": int, # 가용 줄 수
|
||||
}
|
||||
"""
|
||||
topic_count = max(1, len(container_spec.topic_ids))
|
||||
|
||||
# 같은 컨테이너 내 블록 간 gap 차감
|
||||
total_gap = _CONTAINER_BLOCK_GAP * max(0, topic_count - 1)
|
||||
available_container_height = max(40, container_spec.height_px - total_gap)
|
||||
per_topic_px = available_container_height // topic_count
|
||||
|
||||
# 폰트 크기 결정
|
||||
font_size, padding, line_h = _determine_typography(per_topic_px)
|
||||
|
||||
# 구조적 오버헤드
|
||||
structural = _BLOCK_STRUCTURAL_OVERHEAD.get(block_type, 20)
|
||||
content_height = max(10, per_topic_px - structural)
|
||||
|
||||
# 줄 수 계산
|
||||
line_height_px = font_size * line_h
|
||||
available_lines = max(1, int(content_height / line_height_px))
|
||||
|
||||
# 한국어 줄당 글자수 (폰트 크기 기반)
|
||||
usable_width = container_spec.width_px * 0.85 # 패딩 제외
|
||||
chars_per_line = max(5, int(usable_width / font_size))
|
||||
|
||||
# 항목 수 제한 (블록 정의 참조)
|
||||
max_items_by_space = max(1, available_lines // 2) # 항목당 최소 2줄
|
||||
catalog_max = 10
|
||||
catalog_min = 1
|
||||
if block_def:
|
||||
catalog_max = block_def.get("max_items", 10)
|
||||
catalog_min = block_def.get("min_items", 1)
|
||||
max_items = min(max_items_by_space, catalog_max)
|
||||
max_items = max(max_items, catalog_min)
|
||||
|
||||
total_chars = available_lines * chars_per_line
|
||||
chars_per_item = total_chars // max(1, max_items)
|
||||
|
||||
budget = {
|
||||
"total_chars": max(20, total_chars),
|
||||
"max_items": max_items,
|
||||
"chars_per_item": max(10, chars_per_item),
|
||||
"font_size_px": font_size,
|
||||
"available_lines": available_lines,
|
||||
}
|
||||
|
||||
logger.info(
|
||||
f"[Q-3] 예산: {block_type} → {budget['total_chars']}자, "
|
||||
f"{budget['max_items']}항목, {budget['font_size_px']}px"
|
||||
)
|
||||
return budget
|
||||
|
||||
|
||||
def calculate_budgets_for_candidates(
|
||||
candidates: list[dict],
|
||||
container_spec: ContainerSpec,
|
||||
) -> dict[str, dict]:
|
||||
"""후보 블록 리스트의 각 블록에 대해 글자수 예산을 계산한다.
|
||||
|
||||
Returns:
|
||||
{"block_id": {"total_chars": ..., "max_items": ..., ...}, ...}
|
||||
"""
|
||||
budgets = {}
|
||||
for block in candidates:
|
||||
block_id = block.get("id", "")
|
||||
budgets[block_id] = calculate_char_budget(
|
||||
block_id, container_spec, block_def=block
|
||||
)
|
||||
return budgets
|
||||
|
||||
|
||||
# ──────────────────────────────────────
|
||||
# Phase Q-7: LaTeX 글루 모델 (overflow 수학적 조정)
|
||||
# ──────────────────────────────────────
|
||||
|
||||
@dataclass
|
||||
class GlueSpec:
|
||||
"""LaTeX 글루 모델: 유연한 간격 정의.
|
||||
|
||||
natural: 기본 간격 (px)
|
||||
stretch: 늘어날 수 있는 양 (px)
|
||||
shrink: 줄어들 수 있는 양 (px)
|
||||
"""
|
||||
natural: float
|
||||
stretch: float
|
||||
shrink: float
|
||||
|
||||
|
||||
# 간격 유형별 글루 설정
|
||||
SPACING_GLUE: dict[str, GlueSpec] = {
|
||||
"block_gap": GlueSpec(natural=20, stretch=4, shrink=12),
|
||||
"inner_gap": GlueSpec(natural=16, stretch=4, shrink=8),
|
||||
"title_gap": GlueSpec(natural=8, stretch=2, shrink=4),
|
||||
"container_padding": GlueSpec(natural=16, stretch=0, shrink=8),
|
||||
}
|
||||
|
||||
# 폰트 크기 축소 단계 (이진 탐색용)
|
||||
FONT_SIZE_STEPS = [15.2, 14.0, 13.0, 12.0, 11.0, 10.0, 9.0, 8.0]
|
||||
|
||||
|
||||
def calculate_glue_absorption(block_count: int) -> float:
|
||||
"""글루 모델로 흡수 가능한 최대 px를 계산한다.
|
||||
|
||||
Args:
|
||||
block_count: 컨테이너 내 블록 수
|
||||
|
||||
Returns:
|
||||
흡수 가능한 총 shrink px
|
||||
"""
|
||||
total_shrink = 0.0
|
||||
# 블록 간 간격
|
||||
total_shrink += SPACING_GLUE["block_gap"].shrink * max(0, block_count - 1)
|
||||
# 각 블록 내부 간격
|
||||
total_shrink += SPACING_GLUE["inner_gap"].shrink * block_count
|
||||
# 제목 간격
|
||||
total_shrink += SPACING_GLUE["title_gap"].shrink * block_count
|
||||
# 컨테이너 패딩 (상하)
|
||||
total_shrink += SPACING_GLUE["container_padding"].shrink * 2
|
||||
|
||||
return total_shrink
|
||||
|
||||
|
||||
def compute_glue_css_overrides(
|
||||
excess_px: float,
|
||||
block_count: int,
|
||||
) -> dict[str, str]:
|
||||
"""overflow excess를 흡수하기 위한 CSS 변수 오버라이드를 계산한다.
|
||||
|
||||
Returns:
|
||||
{"--spacing-block": "8px", "--spacing-inner": "8px", ...} 또는
|
||||
None (글루만으로 흡수 불가)
|
||||
"""
|
||||
max_absorption = calculate_glue_absorption(block_count)
|
||||
if excess_px <= 0:
|
||||
return {}
|
||||
|
||||
if excess_px > max_absorption:
|
||||
# 글루만으로 부족 — 부분 축소 적용 후 나머지는 폰트 축소 필요
|
||||
ratio = 1.0
|
||||
else:
|
||||
ratio = excess_px / max_absorption
|
||||
|
||||
overrides = {}
|
||||
|
||||
# 비율에 따라 각 간격 축소
|
||||
block_gap = SPACING_GLUE["block_gap"]
|
||||
new_block_gap = block_gap.natural - block_gap.shrink * ratio
|
||||
overrides["--spacing-block"] = f"{new_block_gap:.0f}px"
|
||||
|
||||
inner_gap = SPACING_GLUE["inner_gap"]
|
||||
new_inner_gap = inner_gap.natural - inner_gap.shrink * ratio
|
||||
overrides["--spacing-inner"] = f"{new_inner_gap:.0f}px"
|
||||
|
||||
padding = SPACING_GLUE["container_padding"]
|
||||
new_padding = padding.natural - padding.shrink * ratio
|
||||
overrides["--container-padding"] = f"{new_padding:.0f}px"
|
||||
|
||||
logger.info(
|
||||
f"[Q-7] 글루 압축: excess={excess_px:.0f}px, "
|
||||
f"absorption={max_absorption:.0f}px, ratio={ratio:.2f}"
|
||||
)
|
||||
return overrides
|
||||
|
||||
|
||||
def find_fitting_font_size(
|
||||
current_font_px: float,
|
||||
excess_after_glue_px: float,
|
||||
available_lines: int,
|
||||
chars_per_line: int,
|
||||
) -> float | None:
|
||||
"""글루 압축 후에도 남은 overflow를 폰트 축소로 해결할 수 있는지 확인.
|
||||
|
||||
Returns:
|
||||
적합한 폰트 크기 (px) 또는 None (불가능)
|
||||
"""
|
||||
for font_size in FONT_SIZE_STEPS:
|
||||
if font_size >= current_font_px:
|
||||
continue # 현재보다 같거나 큰 크기는 스킵
|
||||
|
||||
# 이 폰트에서의 줄 높이
|
||||
line_height_px = font_size * 1.6 # 한국어 기본
|
||||
height_saved = (current_font_px * 1.6 - line_height_px) * available_lines
|
||||
|
||||
if height_saved >= excess_after_glue_px:
|
||||
logger.info(
|
||||
f"[Q-7] 폰트 축소: {current_font_px}px → {font_size}px "
|
||||
f"({height_saved:.0f}px 확보)"
|
||||
)
|
||||
return font_size
|
||||
|
||||
return None # 8px에서도 안 맞으면 AI 텍스트 압축 필요
|
||||
|
||||
Reference in New Issue
Block a user