# Phase O: 컨테이너 기반 레이아웃 시스템 > 작성일: 2026-03-27 > 상태: ✅ 코드 구현 완료 + 후속 정리 완료 (Step B 제거, 죽은 코드 정리, 미해결 3건 해결) > 선행 완료: Phase N (catalog 개선, fallback 제거, topic_id 버그 수정) --- ## 핵심 원칙 **"비중이 컨테이너를 확정하고, 컨테이너가 블록을 제약하고, 블록이 콘텐츠를 제약한다."** ``` Kei 비중 판단 (본심 60%, 배경 20%) ↓ 컨테이너 px 확정 (본심 294px, 배경 98px) ↓ 블록 선택 시 컨테이너 크기 제약 (98px → compact 블록만) ↓ 블록 스펙 확정 (항목 수, 폰트, 패딩, 행 수) ↓ 편집자가 확정 스펙에 맞게 텍스트 작성 ↓ 렌더링 (컨테이너 grid로 비중 강제 반영) ``` --- ## 현재 문제 (Phase N 이후에도 남은 것) ### 문제 1: 비중이 시각에 반영 안 됨 - Kei가 본심 60%, 배경 20%로 판단했지만 - 실제 렌더링에서 배경이 73%(348px), 본심이 20%(97px) - **원인:** 블록이 자연 높이대로 렌더링되고, 비중 기반 컨테이너가 없음 ### 문제 2: 블록 선택 시 컨테이너 크기를 모름 - Kei가 블록을 고를 때 "이 블록이 컨테이너에 들어가는지" 판단 불가 - 98px 컨테이너에 height_cost=large 블록이 선택됨 ### 문제 3: 블록이 컨테이너에 맞게 변형되지 않음 - 같은 `dark-bullet-list`여도 98px이면 불릿 2개, 294px이면 5개여야 하는데 - 현재는 블록이 고정 형태로 렌더링됨 ### 문제 4: 텍스트 분량이 컨테이너와 무관 - sidebar 490px인데 용어 정의가 한 줄짜리 - body 98px인데 문제제기가 3단 구조 --- ## 변경 대상 파일 및 역할 | 파일 | 현재 역할 | Phase O 변경 | |------|----------|------------| | `pipeline.py` | 5단계 오케스트레이션 | 컨테이너 계산을 Step A와 A-2 사이에 삽입 | | `space_allocator.py` | _max_chars만 계산 | **컨테이너 스펙 생성기로 확장** (px, 블록 제약, 항목수, 폰트, 글자수) | | `design_director.py` | Step A-2에서 블록 선택 | 컨테이너 px를 Kei에게 전달 + height_cost 제약 | | `content_editor.py` | _max_chars로 분량 제한 | 블록 스펙(항목수, 글자수/항목)을 프롬프트에 전달 | | `renderer.py` | flex-column으로 블록 나열 | **비중 기반 grid row로 컨테이너 생성** | | `catalog.yaml` | when/not_for 설명 | 각 블록의 height_cost를 px 범위로 구체화 | --- ## 단계별 상세 설계 ### O-1. 컨테이너 스펙 계산 (`space_allocator.py` 확장) **현재:** `allocate_height_budget()` → `{topic_id: max_height_px}` 딕셔너리만 반환 **변경:** `calculate_container_specs()` → 각 컨테이너의 완전한 스펙을 반환 ```python def calculate_container_specs( page_structure: dict, # Kei의 비중 판단: {"본심": {"topic_ids": [3], "weight": 0.6}, ...} topics: list[dict], # 각 topic의 purpose, role, layer preset: dict, # 프리셋 zone 정보 (budget_px, width_pct) ) -> dict[str, ContainerSpec]: """Kei 비중 → 컨테이너 스펙 변환. Returns: 역할별 ContainerSpec 딕셔너리. 예: { "본심": ContainerSpec( role="본심", zone="body", topic_ids=[3], weight=0.6, height_px=294, # zone_budget × weight_ratio width_px=716, # slide_width × zone_width_pct × 0.85 (패딩 제외) max_height_cost="xlarge", # 294px이면 xlarge까지 가능 block_constraints={ "max_items": 7, # 높이 기반 계산 "font_size_px": 15.2, # 기본값 유지 가능 "padding_px": 20, # 기본값 유지 가능 "max_chars_total": 800, # 높이×너비 기반 총 글자수 }, ), "배경": ContainerSpec( role="배경", zone="body", topic_ids=[1, 2], weight=0.2, height_px=98, width_px=716, max_height_cost="compact", # 98px이면 compact만 block_constraints={ "max_items": 3, "font_size_px": 13.0, # 줄여야 함 "padding_px": 10, # 줄여야 함 "max_chars_total": 200, }, ), ... } """ ``` **height_cost → px 매핑:** 현재 catalog.yaml의 height_cost는 문자열(`compact`, `medium`, `large`, `xlarge`)이다. 이것을 px 범위로 매핑해야 Kei가 블록을 고를 때 컨테이너에 맞는지 판단할 수 있다. ```python HEIGHT_COST_PX_RANGE = { "compact": (30, 80), # 30~80px "medium": (80, 200), # 80~200px "large": (200, 350), # 200~350px "xlarge": (350, 500), # 350~500px } ``` **컨테이너 높이 → 허용 height_cost 결정:** ```python 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" ``` **블록 내부 제약 계산:** ```python def calculate_block_constraints( height_px: int, width_px: int, topic_count: int, # 이 컨테이너에 들어가는 topic 수 font_size_px: float, line_height: float, padding_px: int, ) -> dict: """컨테이너 크기에서 블록 내부 제약을 수학적으로 계산.""" # 각 topic에 할당되는 높이 per_topic_height = (height_px - padding_px * 2) / topic_count # 줄 수 line_height_px = font_size_px * line_height max_lines = int(per_topic_height / line_height_px) # 줄당 글자 수 chars_per_line = int((width_px - padding_px * 2) / (font_size_px * 0.95)) # 불릿/항목 수 (한 항목 = 약 2줄) 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_chars_total, "max_chars_per_item": max(20, max_chars_total // max(1, max_items)), } ``` **폰트/패딩 조정 기준:** | 컨테이너 높이 | 폰트 크기 | 패딩 | line-height | |-------------|---------|------|-----------| | ≥300px | 15.2px (기본) | 20px (기본) | 1.7 (기본) | | 150~299px | 14px | 14px | 1.6 | | 80~149px | 13px | 10px | 1.5 | | <80px | 12px | 8px | 1.4 | --- ### O-2. 블록 선택에 컨테이너 제약 전달 (`design_director.py`) **현재:** `_opus_block_recommendation()`이 Kei에게 블록 후보 + 꼭지 목록을 보냄. 컨테이너 크기 정보 없음. **변경:** 컨테이너 스펙을 Kei에게 함께 전달. ```python # _opus_block_recommendation 프롬프트에 추가할 내용 container_text = "\n".join( f"- 꼭지 {tid}: 컨테이너 {spec.height_px}px × {spec.width_px}px, " f"허용 height_cost: {spec.max_height_cost} 이하, " f"최대 항목 수: {spec.block_constraints['max_items']}" for role, spec in container_specs.items() for tid in spec.topic_ids ) prompt += ( f"\n\n## 컨테이너 제약 (반드시 준수)\n" f"각 꼭지의 블록은 아래 컨테이너 안에 들어가야 한다.\n" f"height_cost가 컨테이너보다 크면 선택 금지.\n\n" f"{container_text}\n" ) ``` **코드 레벨 검증 (Kei 응답 후):** ```python # Kei가 선택한 블록의 height_cost가 컨테이너보다 큰지 검증 for rec in kei_recommendations: tid = rec.get("topic_id") or rec.get("id") block_type = rec.get("block_type", "") # catalog에서 height_cost 조회 block_height_cost = catalog_map.get(block_type, {}).get("height_cost", "medium") # 컨테이너의 max_height_cost 조회 container_spec = find_container_for_topic(tid, container_specs) allowed = container_spec.max_height_cost # 제약 위반 체크 if HEIGHT_COST_ORDER[block_height_cost] > HEIGHT_COST_ORDER[allowed]: logger.warning( f"[O-2 검증] 꼭지 {tid}: {block_type}({block_height_cost})이 " f"컨테이너({container_spec.height_px}px, {allowed} 이하)에 안 맞음" ) # 위반 시 → Kei에게 재선택 요청 (컨테이너 제약 명시) ``` --- ### O-3. 블록 스펙 확정 단계 (신규) **현재:** 없음. 블록이 선택되면 바로 편집자에게 전달. **변경:** Step A-2 후, Step 3 전에 **블록 스펙 확정** 단계 삽입. 이 단계는 **코드(결정론적)** — AI 호출 없음. ```python def finalize_block_specs( blocks: list[dict], # Step A-2에서 확정된 블록 목록 container_specs: dict, # O-1에서 계산된 컨테이너 스펙 catalog: dict, # catalog.yaml 데이터 ) -> list[dict]: """각 블록의 내부 스펙을 컨테이너 크기에 맞게 확정한다. 확정 항목: - _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 block_type = block.get("type", "") catalog_info = catalog.get(block_type, {}) # 이 블록이 쓸 수 있는 높이 (같은 컨테이너 안의 다른 블록과 분배) siblings_in_container = [b for b in blocks if find_container_for_topic(b.get("topic_id"), container_specs) == spec] per_block_height = spec.height_px // len(siblings_in_container) # 폰트/패딩 결정 (컨테이너 크기 기반) font_size, padding, line_h = determine_typography(per_block_height) # 블록별 항목 수 계산 constraints = calculate_block_constraints( per_block_height, spec.width_px, topic_count=1, # 이 블록 1개 font_size_px=font_size, line_height=line_h, padding_px=padding, ) # 블록 타입별 세부 조정 schema = catalog_info.get("schema", {}) if block_type in ("dark-bullet-list",): # 불릿 블록: max_items = 불릿 수 block["_max_items"] = min(constraints["max_items"], int(schema.get("max_bullets", {}).get("body", 5))) block["_max_chars_per_item"] = constraints["max_chars_per_item"] elif block_type in ("card-numbered", "card-icon-desc"): # 카드 블록: max_items = 카드 수 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"): # 표 블록: max_items = 행 수 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: block["_max_chars_total"] = constraints["max_chars_total"] # 공통 block["_container_height_px"] = per_block_height block["_container_width_px"] = spec.width_px block["_font_size_px"] = font_size block["_padding_px"] = padding block["_line_height"] = line_h block["_max_chars_total"] = constraints["max_chars_total"] return blocks ``` **typography 결정 함수:** ```python def determine_typography(height_px: int) -> tuple[float, int, float]: """컨테이너 높이에 따른 폰트/패딩/줄간격 결정.""" if height_px >= 300: return (15.2, 20, 1.7) # 기본 elif height_px >= 150: return (14.0, 14, 1.6) # 약간 축소 elif height_px >= 80: return (13.0, 10, 1.5) # 축소 else: return (12.0, 8, 1.4) # 최소 ``` --- ### O-4. 편집자 프롬프트에 블록 스펙 전달 (`content_editor.py`) **현재:** `_max_chars`만 전달. 항목 수, 항목당 글자 수, 폰트 크기 정보 없음. **변경:** O-3에서 확정된 모든 스펙을 편집자에게 전달. ```python # fill_content()에서 각 블록의 스펙을 프롬프트에 구체적으로 명시 for i, block in enumerate(blocks): req_text = ( f"블록 {i+1} ({block_type}, 영역: {block.get('area')}):\n" f" 목적(purpose): {block.get('purpose')}\n" f" 필수 슬롯: {slots.get('required', [])}\n" ) # O-4: 블록 스펙 (컨테이너 기반) container_h = block.get("_container_height_px") if container_h: max_items = block.get("_max_items", "제한 없음") max_chars_item = block.get("_max_chars_per_item", "제한 없음") max_chars_total = block.get("_max_chars_total", "제한 없음") font_size = block.get("_font_size_px", 15.2) req_text += ( f"\n ★ 컨테이너 제약 (절대 준수):\n" f" - 컨테이너 높이: {container_h}px\n" f" - 최대 항목 수: {max_items}개\n" f" - 항목당 최대 글자 수: {max_chars_item}자\n" f" - 총 최대 글자 수: {max_chars_total}자\n" f" - 폰트 크기: {font_size}px\n" f" 이 제약을 넘기면 컨테이너 밖으로 넘친다. 반드시 지켜라.\n" ) ``` **sidebar 용어 정의 예시:** ``` 블록 5 (card-numbered, 영역: sidebar): 목적(purpose): 용어정의 ★ 컨테이너 제약: - 컨테이너 높이: 450px (sidebar 전체) - 최대 항목 수: 3개 - 항목당 최대 글자 수: 120자 ← 출처까지 넣을 수 있는 여유 - 폰트 크기: 13px ``` **body 배경(98px) 예시:** ``` 블록 2 (dark-bullet-list, 영역: body): 목적(purpose): 근거사례 ★ 컨테이너 제약: - 컨테이너 높이: 49px (배경 98px / 2 topics) - 최대 항목 수: 2개 - 항목당 최대 글자 수: 40자 ← 간결하게 - 폰트 크기: 12px ``` --- ### O-5. 렌더러에서 비중 기반 grid row 생성 (`renderer.py`) **현재:** `_group_blocks_by_area()`가 같은 area 블록을 flex-column으로 나열. 높이 비율 없음. **변경:** body zone 안에 역할(본심/배경/결론)별 grid row를 생성하고, 각 row의 높이를 비중 기반으로 확정. ```python def _group_blocks_by_area_with_containers( blocks: list[dict[str, Any]], container_specs: dict | None = None, ) -> list[dict[str, Any]]: """같은 area의 블록들을 비중 기반 컨테이너로 그룹핑한다. container_specs가 있으면: - body zone 안에서 역할별 컨테이너 div를 생성 - 각 컨테이너의 height를 비중 기반 px로 고정 - 블록은 해당 컨테이너 안에 배치 container_specs가 없으면: - 기존 flex-column 방식 (호환) """ grouped = OrderedDict() for block in blocks: area = block["area"] if area not in grouped: grouped[area] = {"area": area, "blocks": []} grouped[area]["blocks"].append(block) result = [] for area, data in grouped.items(): block_list = data["blocks"] if container_specs and area == "body": # 비중 기반 컨테이너 생성 # container_specs에서 이 area의 역할별 높이를 가져옴 container_htmls = [] # 역할 순서: 배경 → 본심 → (결론은 footer) role_order = ["배경", "본심"] for role in role_order: spec = container_specs.get(role) if not spec or spec.zone != area: continue # 이 역할에 해당하는 블록들 role_blocks = [ b for b in block_list if b.get("_topic_id_role") == role or b.get("topic_id") in spec.topic_ids ] if not role_blocks: continue inner_html = "\n".join(b["html"] for b in role_blocks) # 컨테이너 div: 높이 고정 + overflow visible (측정용) font_size = spec.block_constraints.get("font_size_px", 15.2) padding = spec.block_constraints.get("padding_px", 20) container_htmls.append( f'