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:
2026-03-27 15:20:51 +09:00
parent ffad1ba82a
commit b0bcffc0f6
28 changed files with 8450 additions and 1530 deletions

312
src/space_allocator.py Normal file
View File

@@ -0,0 +1,312 @@
"""Phase O: 컨테이너 기반 공간 할당 시스템.
Kei 비중 → 컨테이너 px 확정 → 블록 제약 계산 → 편집자 스펙 생성.
LLM 추정이 아닌 결정론적 계산.
주요 함수:
- calculate_container_specs(): Kei 비중 → 역할별 ContainerSpec
- finalize_block_specs(): 컨테이너 크기 → 블록별 내부 스펙
- calculate_trim_chars(): 초과 px → 삭제 글자 수
"""
from __future__ import annotations
import math
import logging
from dataclasses import dataclass, field
from typing import Any
logger = logging.getLogger(__name__)
# ──────────────────────────────────────
# height_cost → px 범위 매핑
# ──────────────────────────────────────
HEIGHT_COST_PX_RANGE = {
"compact": (30, 80),
"medium": (80, 200),
"large": (200, 350),
"xlarge": (350, 500),
}
HEIGHT_COST_ORDER = {"compact": 0, "medium": 1, "large": 2, "xlarge": 3}
# 역할별 zone 매핑 (기본)
ROLE_ZONE_MAP = {
"본심": "body",
"배경": "body",
"첨부": "sidebar",
"결론": "footer",
}
# 폰트 설정 기본값
DEFAULT_FONT_SIZE_PX = 15.2
DEFAULT_LINE_HEIGHT = 1.7
DEFAULT_AVG_CHAR_WIDTH_PX = 14.4 # fonttools 실측 기반 (Pretendard 한글)
# ──────────────────────────────────────
# ContainerSpec 데이터 클래스
# ──────────────────────────────────────
@dataclass
class ContainerSpec:
"""역할별 컨테이너 스펙."""
role: str # "본심", "배경", "첨부", "결론"
zone: str # "body", "sidebar", "footer"
topic_ids: list[int] # 이 컨테이너에 속하는 topic ID들
weight: float # Kei가 판단한 비중 (0.0~1.0)
height_px: int # 컨테이너 높이 (px)
width_px: int # 컨테이너 너비 (px)
max_height_cost: str # 허용 최대 height_cost ("compact"/"medium"/"large"/"xlarge")
block_constraints: dict = field(default_factory=dict) # 블록 내부 제약
# ──────────────────────────────────────
# O-1: 컨테이너 스펙 계산
# ──────────────────────────────────────
def calculate_container_specs(
page_structure: dict[str, Any],
topics: list[dict[str, Any]],
preset: dict[str, Any],
slide_width: int = 1280,
slide_height: int = 720,
gap_px: int = 20,
) -> dict[str, ContainerSpec]:
"""Kei 비중 → 역할별 ContainerSpec 계산.
결정론적. AI 호출 없음.
Args:
page_structure: Kei 판단 {"본심": {"topic_ids": [3], "weight": 0.6}, ...}
topics: 각 topic의 purpose, role, layer
preset: 프리셋 zone 정보 (budget_px, width_pct)
slide_width: 슬라이드 너비 (px)
slide_height: 슬라이드 높이 (px)
gap_px: 컨테이너 간 간격 (px)
Returns:
{"본심": ContainerSpec(...), "배경": ContainerSpec(...), ...}
"""
zones = preset.get("zones", {})
specs: dict[str, ContainerSpec] = {}
# zone별로 해당 역할들의 비중 합산
zone_roles: dict[str, list[tuple[str, dict]]] = {} # zone → [(role, info), ...]
for role_name, role_info in page_structure.items():
if not isinstance(role_info, dict):
continue
zone = ROLE_ZONE_MAP.get(role_name, "body")
if zone not in zone_roles:
zone_roles[zone] = []
zone_roles[zone].append((role_name, role_info))
for zone_name, role_list in zone_roles.items():
zone_info = zones.get(zone_name, {})
zone_budget = zone_info.get("budget_px", 490)
zone_width_pct = zone_info.get("width_pct", 100)
zone_width_px = int(slide_width * zone_width_pct / 100 * 0.85) # 패딩 제외
# 이 zone 안의 역할별 비중 비율 계산
total_weight = sum(info.get("weight", 0.25) for _, info in role_list)
if total_weight <= 0:
total_weight = 1.0
# 간격 제외
total_gap = gap_px * max(0, len(role_list) - 1)
available = zone_budget - total_gap
for role_name, role_info in role_list:
weight = role_info.get("weight", 0.25)
topic_ids = role_info.get("topic_ids", [])
# 비중 비율로 높이 할당
ratio = weight / total_weight
height_px = max(50, int(available * ratio))
# 블록 내부 제약 계산 — topic당 높이로 판단
topic_count = max(1, len(topic_ids))
per_topic_px = height_px // topic_count
# height_cost 허용 범위: topic당 높이 기준 (컨테이너 전체가 아님)
max_cost = _max_allowed_height_cost(per_topic_px)
font_size, padding, line_h = _determine_typography(height_px // topic_count)
constraints = _calculate_block_constraints(
height_px, zone_width_px, topic_count, font_size, line_h, padding
)
constraints["font_size_px"] = font_size
constraints["padding_px"] = padding
constraints["line_height"] = line_h
specs[role_name] = ContainerSpec(
role=role_name,
zone=zone_name,
topic_ids=topic_ids,
weight=weight,
height_px=height_px,
width_px=zone_width_px,
max_height_cost=max_cost,
block_constraints=constraints,
)
logger.info(
f"[O-1] 컨테이너 스펙: "
+ ", ".join(f"{r}={s.height_px}px({s.max_height_cost})" for r, s in specs.items())
)
return specs
def _max_allowed_height_cost(container_height_px: int) -> str:
"""컨테이너 높이에서 허용되는 최대 height_cost."""
if container_height_px >= 350:
return "xlarge"
elif container_height_px >= 200:
return "large"
elif container_height_px >= 80:
return "medium"
else:
return "compact"
def _determine_typography(per_block_height_px: int) -> tuple[float, int, float]:
"""컨테이너 높이에 따른 폰트/패딩/줄간격 결정."""
if per_block_height_px >= 300:
return (15.2, 20, 1.7)
elif per_block_height_px >= 150:
return (14.0, 14, 1.6)
elif per_block_height_px >= 80:
return (13.0, 10, 1.5)
else:
return (12.0, 8, 1.4)
def _calculate_block_constraints(
height_px: int,
width_px: int,
topic_count: int,
font_size_px: float,
line_height: float,
padding_px: int,
) -> dict:
"""컨테이너 크기에서 블록 내부 제약을 수학적으로 계산."""
per_topic_height = max(30, (height_px - padding_px * 2) // topic_count)
line_height_px = font_size_px * line_height
max_lines = max(1, int(per_topic_height / line_height_px))
chars_per_line = max(5, int((width_px - padding_px * 2) / (font_size_px * 0.95)))
max_items = max(1, max_lines // 2)
max_chars_total = max_lines * chars_per_line
return {
"max_lines": max_lines,
"max_items": max_items,
"chars_per_line": chars_per_line,
"max_chars_total": max(20, max_chars_total),
"max_chars_per_item": max(20, max_chars_total // max(1, max_items)),
}
# ──────────────────────────────────────
# O-1 유틸: topic_id → ContainerSpec 매핑
# ──────────────────────────────────────
def find_container_for_topic(
topic_id: int | None,
container_specs: dict[str, ContainerSpec],
) -> ContainerSpec | None:
"""topic_id로 해당 ContainerSpec을 찾는다."""
if topic_id is None:
return None
for spec in container_specs.values():
if topic_id in spec.topic_ids:
return spec
return None
# ──────────────────────────────────────
# O-3: 블록 스펙 확정
# ──────────────────────────────────────
def finalize_block_specs(
blocks: list[dict[str, Any]],
container_specs: dict[str, ContainerSpec],
catalog_map: dict[str, dict] | None = None,
) -> list[dict[str, Any]]:
"""각 블록의 내부 스펙을 컨테이너 크기에 맞게 확정한다.
결정론적. AI 호출 없음.
확정 필드:
- _container_height_px, _container_width_px
- _max_items, _max_chars_per_item, _max_chars_total
- _font_size_px, _padding_px, _line_height
"""
for block in blocks:
tid = block.get("topic_id")
spec = find_container_for_topic(tid, container_specs)
if not spec:
continue
# 같은 컨테이너 안의 블록 수 (높이 분배)
siblings = [b for b in blocks
if find_container_for_topic(b.get("topic_id"), container_specs) == spec
and b.get("topic_id") is not None]
sibling_count = max(1, len(siblings))
per_block_height = max(40, spec.height_px // sibling_count)
# 폰트/패딩 결정
font_size, padding, line_h = _determine_typography(per_block_height)
# 블록별 제약 계산
constraints = _calculate_block_constraints(
per_block_height, spec.width_px, 1, font_size, line_h, padding
)
# 블록 타입별 세부 조정
block_type = block.get("type", "")
if block_type in ("dark-bullet-list",):
block["_max_items"] = min(constraints["max_items"], 5)
block["_max_chars_per_item"] = constraints["max_chars_per_item"]
elif block_type in ("card-numbered", "card-icon-desc"):
block["_max_items"] = constraints["max_items"]
block["_max_chars_per_item"] = constraints["max_chars_per_item"]
elif block_type in ("compare-2col-split", "compare-3col-badge", "table-simple-striped"):
block["_max_items"] = constraints["max_items"] # 행 수
block["_max_chars_per_item"] = constraints["max_chars_per_item"]
elif block_type in ("comparison-2col",):
block["_max_chars_per_item"] = constraints["max_chars_total"] // 2
elif block_type in ("banner-gradient",):
block["_max_chars_total"] = constraints["chars_per_line"]
else:
pass # 기본값 사용
# 공통 필드
block["_container_height_px"] = per_block_height
block["_container_width_px"] = spec.width_px
block["_max_chars_total"] = constraints["max_chars_total"]
block["_font_size_px"] = font_size
block["_padding_px"] = padding
block["_line_height"] = line_h
logger.info(
f"[O-3] 블록 스펙 확정: "
+ ", ".join(
f"t{b.get('topic_id')}={b.get('_container_height_px','?')}px"
for b in blocks if b.get("topic_id") is not None
)
)
return blocks
# ──────────────────────────────────────
# 기존 유틸 (Phase L 호환)
# ──────────────────────────────────────
def calculate_trim_chars(
excess_px: int,
container_width_px: int,
font_size_px: float = DEFAULT_FONT_SIZE_PX,
line_height: float = DEFAULT_LINE_HEIGHT,
avg_char_width_px: float = DEFAULT_AVG_CHAR_WIDTH_PX,
) -> int:
"""초과 px에서 삭제할 글자 수를 계산한다."""
if excess_px <= 0:
return 0
line_height_px = font_size_px * line_height
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)