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:
312
src/space_allocator.py
Normal file
312
src/space_allocator.py
Normal 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)
|
||||
Reference in New Issue
Block a user