- 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>
709 lines
26 KiB
Markdown
709 lines
26 KiB
Markdown
# 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'<div class="container-{role}" style="'
|
||
f'height:{spec.height_px}px; '
|
||
f'overflow:visible; '
|
||
f'font-size:{font_size}px; '
|
||
f'--spacing-inner:{padding}px; '
|
||
f'--font-body:{font_size / 16:.3f}rem;">\n'
|
||
f'{inner_html}\n</div>'
|
||
)
|
||
|
||
html = "\n".join(container_htmls)
|
||
|
||
elif len(block_list) == 1:
|
||
html = block_list[0]["html"]
|
||
else:
|
||
inner = "\n".join(b["html"] for b in block_list)
|
||
html = (
|
||
f'<div style="display:flex; flex-direction:column; '
|
||
f'gap:var(--spacing-block); height:100%;">\n'
|
||
f'{inner}\n</div>'
|
||
)
|
||
|
||
result.append({"area": area, "html": html})
|
||
|
||
return result
|
||
```
|
||
|
||
**CSS 구조 (렌더링 결과):**
|
||
```html
|
||
<!-- body zone -->
|
||
<div class="area-body">
|
||
<!-- 배경 컨테이너: 98px 고정 -->
|
||
<div class="container-배경" style="height:98px; overflow:visible; font-size:13px;">
|
||
<!-- topic 1: comparison-2col -->
|
||
<!-- topic 2: dark-bullet-list -->
|
||
</div>
|
||
|
||
<!-- 본심 컨테이너: 294px 고정 -->
|
||
<div class="container-본심" style="height:294px; overflow:visible; font-size:15.2px;">
|
||
<!-- topic 3: compare-2col-split -->
|
||
</div>
|
||
</div>
|
||
|
||
<!-- footer: 60px -->
|
||
<div class="area-footer" style="height:60px;">
|
||
<!-- topic 5: banner-gradient -->
|
||
</div>
|
||
|
||
<!-- sidebar: 490px -->
|
||
<div class="area-sidebar">
|
||
<!-- topic 4: card-numbered (여유로운 공간) -->
|
||
</div>
|
||
```
|
||
|
||
---
|
||
|
||
### O-6. 파이프라인 흐름 변경 (`pipeline.py`)
|
||
|
||
**현재 흐름:**
|
||
```
|
||
1A(Kei 꼭지) → 1B(컨셉) → A-2(블록선택) → B(zone배치) → 공간할당 → 3(편집) → 4(CSS+렌더) → 측정 → 5(검수)
|
||
```
|
||
|
||
**변경 후:**
|
||
```
|
||
1A(Kei 꼭지 + 비중)
|
||
↓
|
||
1B(Kei 컨셉)
|
||
↓
|
||
★ 컨테이너 스펙 계산 (O-1, 코드/결정론적)
|
||
↓
|
||
A-2(Kei 블록선택 — 컨테이너 제약 전달) (O-2)
|
||
↓
|
||
B(Sonnet zone + char_guide)
|
||
↓
|
||
★ 블록 스펙 확정 (O-3, 코드/결정론적)
|
||
↓
|
||
3(Kei 편집 — 블록 스펙 전달) (O-4)
|
||
↓
|
||
4(렌더링 — 컨테이너 grid) (O-5)
|
||
↓
|
||
측정(Selenium)
|
||
↓
|
||
5(Kei 검수)
|
||
```
|
||
|
||
**pipeline.py 변경 위치:**
|
||
|
||
```python
|
||
# 현재 코드 위치: pipeline.py 105행 부근 (2단계 시작 전)
|
||
|
||
# ★ O-1: 컨테이너 스펙 계산 (1B 완료 후, Step A-2 전)
|
||
yield {"event": "progress", "data": "1.8/5 컨테이너 스펙 계산 중..."}
|
||
|
||
from src.space_allocator import calculate_container_specs
|
||
container_specs = calculate_container_specs(
|
||
page_structure=analysis.get("page_structure", {}),
|
||
topics=analysis.get("topics", []),
|
||
preset=preset,
|
||
)
|
||
_save_step(run_dir, "step1c_containers.json", {
|
||
role: {
|
||
"height_px": spec.height_px,
|
||
"width_px": spec.width_px,
|
||
"max_height_cost": spec.max_height_cost,
|
||
"topic_ids": spec.topic_ids,
|
||
"block_constraints": spec.block_constraints,
|
||
}
|
||
for role, spec in container_specs.items()
|
||
})
|
||
|
||
# 2단계: Step A-2에 container_specs 전달
|
||
layout_concept = await create_layout_concept(content, analysis, container_specs=container_specs)
|
||
|
||
# ★ O-3: 블록 스펙 확정 (Step B 후, Step 3 전)
|
||
from src.space_allocator import finalize_block_specs
|
||
for page in layout_concept.get("pages", []):
|
||
finalize_block_specs(page.get("blocks", []), container_specs, catalog)
|
||
_save_step(run_dir, "step2c_block_specs.json", {
|
||
"blocks": [
|
||
{
|
||
"type": b.get("type"), "topic_id": b.get("topic_id"),
|
||
"_container_height_px": b.get("_container_height_px"),
|
||
"_max_items": b.get("_max_items"),
|
||
"_max_chars_per_item": b.get("_max_chars_per_item"),
|
||
"_max_chars_total": b.get("_max_chars_total"),
|
||
"_font_size_px": b.get("_font_size_px"),
|
||
}
|
||
for p in layout_concept.get("pages", [])
|
||
for b in p.get("blocks", [])
|
||
]
|
||
})
|
||
|
||
# 3단계: 편집자에게 블록 스펙이 전달됨 (O-4는 content_editor.py에서 자동 적용)
|
||
```
|
||
|
||
---
|
||
|
||
### O-7. 중간 산출물 추가 (리포트 반영)
|
||
|
||
**새로 추가되는 중간 산출물:**
|
||
|
||
| 파일 | 단계 | 내용 |
|
||
|------|------|------|
|
||
| `step1c_containers.json` | O-1 | 역할별 컨테이너 스펙 (height_px, width_px, max_height_cost, block_constraints) |
|
||
| `step2c_block_specs.json` | O-3 | 각 블록의 확정 스펙 (_container_height_px, _max_items, _font_size_px 등) |
|
||
|
||
`generate_run_report.py`에 이 2개 단계를 추가한다.
|
||
|
||
---
|
||
|
||
## 실행 순서
|
||
|
||
```
|
||
O-1: space_allocator.py 확장 (ContainerSpec + calculate_container_specs + calculate_block_constraints + determine_typography)
|
||
↓
|
||
O-2: design_director.py 변경 (컨테이너 제약을 Kei에게 전달 + 코드 레벨 height_cost 검증)
|
||
↓
|
||
O-3: space_allocator.py 추가 (finalize_block_specs)
|
||
↓
|
||
O-4: content_editor.py 변경 (블록 스펙을 편집자 프롬프트에 전달)
|
||
↓
|
||
O-5: renderer.py 변경 (비중 기반 grid row 컨테이너 생성)
|
||
↓
|
||
O-6: pipeline.py 변경 (새 단계 삽입 + 중간 산출물 저장)
|
||
↓
|
||
O-7: generate_run_report.py 확장 (새 중간 산출물 표시)
|
||
```
|
||
|
||
**의존 관계:**
|
||
- O-1이 먼저 (나머지 모두 O-1의 ContainerSpec에 의존)
|
||
- O-2, O-3은 O-1 완료 후
|
||
- O-4는 O-3 완료 후
|
||
- O-5는 O-1 완료 후 (O-3과 병렬 가능)
|
||
- O-6은 O-1~O-5 전부 완료 후
|
||
- O-7은 O-6 완료 후
|
||
|
||
---
|
||
|
||
## 검증 기준
|
||
|
||
이 Phase가 완료되면 아래가 반드시 성립해야 한다:
|
||
|
||
1. **비중 = 시각 비율**: Kei가 본심 60%로 판단하면, 실제 렌더링에서 body zone의 60%를 본심 블록이 차지한다
|
||
2. **컨테이너 밖으로 안 넘침**: 각 블록이 자기 컨테이너 높이 안에 들어간다 (overflow:visible이므로 넘치면 Selenium이 감지)
|
||
3. **블록 크기 적합**: 98px 컨테이너에 height_cost=large 블록이 선택되지 않는다
|
||
4. **텍스트 분량 적합**: 490px sidebar에서 용어 정의가 출처까지 포함하고, 98px 배경에서 문제제기가 간결하다
|
||
5. **중간 산출물 확인 가능**: report.html에서 컨테이너 스펙과 블록 스펙을 단계별로 확인할 수 있다
|
||
|
||
---
|
||
|
||
## 기술 조사 결과 반영
|
||
|
||
### 적용하는 것
|
||
- **fonttools** — `calculate_block_constraints()`에서 Pretendard 한글 실측 폭 사용. 하드코딩 `14.0px` 대체. 한글은 uniform-width이므로 정확.
|
||
- **CSS Grid 고정 행** — `grid-template-rows: 98px 294px` 형태로 컨테이너 높이 확정. W3C 표준, 모든 브라우저 지원.
|
||
- **`overflow: visible` + `scrollHeight`** — 컨테이너 높이 고정 + overflow visible → Selenium이 정확히 감지. CSSOM View 스펙 준수.
|
||
|
||
### 적용하지 않는 것
|
||
- **CSS Container Queries** — 38개 블록 템플릿 전부에 `@container` 규칙 추가 필요. Phase O의 핵심 목표(컨테이너 비중 반영)와 무관한 별도 작업. 필요 시 별도 Phase로.
|
||
- **Playwright** — Selenium으로 이미 작동 중. 성능 문제 체감 시 전환.
|
||
- **PPTAgent 방식 (절대 좌표)** — 우리는 콘텐츠마다 비중이 동적으로 변하므로 절대 좌표 방식 부적합.
|
||
|
||
### 조사에서 확인된 사실
|
||
- 기존 도구(Slidev, Marp, reveal.js, PPTAgent) 중 비중 기반 컨테이너 시스템을 쓰는 것은 없음. 우리가 직접 구현.
|
||
- PPTAgent의 `suggested_characters` 개념은 우리 `_max_chars`와 유사하지만, 원본 PPTX 고정값 vs 우리는 동적 계산.
|
||
|
||
---
|
||
|
||
## 기존 코드 충돌 해결 (6건)
|
||
|
||
Phase O 적용 시 기존 코드와 충돌하는 지점과 해결 방법.
|
||
|
||
### 충돌 1: `_max_height_px` vs `_container_height_px`
|
||
- **현재:** pipeline.py:188에서 `block["_max_height_px"]` 설정
|
||
- **해결:** pipeline.py 155~198행(Phase M 공간 할당) 전체를 O-1 `calculate_container_specs()`로 교체
|
||
|
||
### 충돌 2: `allocate_height_budget()` vs `calculate_container_specs()`
|
||
- **현재:** pipeline.py:179에서 `allocate_height_budget()` 호출
|
||
- **해결:** 호출부 교체. `allocate_height_budget()` 함수는 제거하지 않고 `calculate_container_specs()` 내부에서 재사용 가능.
|
||
|
||
### 충돌 3: `_max_chars` 단일값 vs `_max_items` + `_max_chars_per_item`
|
||
- **현재:** content_editor.py:121에서 `block.get("_max_chars")` 체크
|
||
- **해결:** N-3에서 추가한 `_max_chars` 프롬프트 코드를 O-4 블록 스펙으로 교체
|
||
|
||
### 충돌 4: Selenium 측정 스크립트가 container div 못 찾음
|
||
- **현재:** slide_measurer.py:36에서 `[class*="area-"]`만 검색
|
||
- **해결:** `_MEASURE_SCRIPT`에 `.container-*` 셀렉터 추가. container div의 overflow도 감지.
|
||
|
||
### 충돌 5: Phase L 피드백 루프 필드명
|
||
- **현재:** pipeline.py:276에서 `block.get("_max_chars", 400)` 축소
|
||
- **해결:** `_max_chars_total` 또는 `_max_items` 축소로 변경
|
||
|
||
### 충돌 6: fonttools 의존성
|
||
- **현재:** pyproject.toml에 fonttools 없음, Pretendard .ttf 로컬 없음
|
||
- **해결:** `pip install fonttools` + Pretendard .ttf 다운로드 (CDN에서)
|
||
|
||
**원칙:** 모든 충돌은 "기존 코드를 Phase O 코드로 교체"하는 형태. 병존이 아닌 대체. 회귀 없음.
|
||
|
||
---
|
||
|
||
## 변경하지 않는 것
|
||
|
||
- catalog.yaml: Phase N에서 이미 개선 완료. 추가 수정 불필요.
|
||
- kei_client.py: 프롬프트 변경 없음. Kei는 이미 비중을 잘 판단하고 있다.
|
||
- slide_measurer.py: 측정 로직 기본 구조 변경 없음. container 셀렉터만 추가.
|
||
- Kei persona_agent: 수정 없음.
|