# Phase Q: 제약 기반 블록 선택 + 글자수 예산 시스템

> 작성일: 2026-03-28
> 상태: 설계 확정 (사용자 승인 완료, 실행 대기)
> 선행 완료: Phase O (컨테이너 기반 레이아웃), Phase P (다후보 렌더링 비교)

---

## 배경: Phase P 실행 결과 분석

Phase P를 실행한 결과(run `1774599277829`) 최종 슬라이드 품질이 **20/100점**으로 평가됨.

### 발견된 근본 문제 5가지

| # | 근본 원인 | 증상 |
|---|----------|------|
| R1 | FAISS 텍스트 임베딩이 시각 블록을 매칭하지 못함 | "hierarchy" 관계인데 venn-diagram 대신 comparison-2col 선택 |
| R2 | Opus 추천에 catalog 검증 없음 | 존재하지 않는 블록 5개 환각 (arrow-flow, hierarchy-tree 등) |
| R3 | overflow 해소 실패 시 출력 차단/재배치 없음 | 배경 117px에 330px 콘텐츠 → 겹침 상태로 출력 |
| R4 | 블록 중복 사용 제한 없음 | 5개 topic에 3종류 블록만 사용 |
| R5 | 공간 배분이 일방향 | "안 맞아도 강제" — 배경 20%에 topic 2개 우겨넣기 |

### Phase P 접근법의 구조적 문제

```
Phase P: 3후보 렌더링 → 스크린샷 비교 → 선택
문제점: 15번 렌더링 + 15번 AI 호출 → 40분 소요, 10개 폐기
```

**업계 조사 결과**, 다후보 렌더링 비교 방식은 어떤 상용/오픈소스 도구도 사용하지 않음.
- Beautiful.ai: 규칙 엔진이 결정론적으로 배치 (AI는 콘텐츠만)
- Canva: 템플릿 검색 1개 → 커스터마이징
- PPTAgent: 참조 기반 편집 액션으로 1개 생성

**핵심 인사이트:** 블록 유형 선택은 렌더링 전에 결정할 수 있는 문제.
콘텐츠의 relation_type(계층/비교/정의/프로세스)으로 적합한 블록 카테고리가 결정됨.

---

## 핵심 원칙

**"계산 먼저, AI 판단 나중에, 렌더링은 검증만"**

```
Beautiful.ai에서:  AI는 콘텐츠만, 레이아웃은 규칙 엔진이 결정론적으로
Napkin.ai에서:    relation_type → 시각화 유형 자동 매핑
학술 연구에서:    글자수 예산을 사전 계산하여 AI에 제약으로 전달
VASCAR에서:      렌더링 → 비전 모델 검증 → 교정 루프
```

### 블록의 정체 재정의

```
블록 = 시각 패턴 (구조)     ← 제목+본문이 세로 나열, 원이 겹침, 좌우 비교 등
블록 ≠ 고정 크기 컴포넌트   ← "제목 1줄 + 본문 1줄"이 아님

컨테이너가 크기를 결정:
  같은 card-numbered라도
  - 352px 컨테이너 → 항목 5개, 14px, 항목당 120자
  - 117px 컨테이너 → 항목 2개, 12px, 항목당 40자
  -  58px 컨테이너 → 항목 1개, 10px, 항목당 20자

각 블록에는 "최소 생존 크기"가 존재:
  venn-diagram:     최소 ~150px (원이 의미 있으려면)
  card-numbered:    최소 ~55px  (항목 1개)
  banner-gradient:  최소 ~40px  (텍스트 1줄)
  divider-text:     최소 ~25px  (선 + 텍스트)
```

---

## 새 프로세스 vs 현재 프로세스

```
[현재 — Phase P]                          [Phase Q]

1. Kei 분석 (topics, weights)             1. Kei 분석 (동일)
2. 컨테이너 계산 (weight→px)              2. 컨테이너 계산 (동일)

3. FAISS 2개 + Opus 1개 = 3후보           3. relation_type → 블록 카테고리 (코드)
4. 3후보 × 5topics = 15개 텍스트 편집        → 컨테이너 제약 필터링 (코드)
5. 15개 Selenium 렌더링 + 스크린샷            → 글자수 예산 계산 (코드)
6. Kei 스크린샷 비교 → 5개 선택               → Kei에게 2-3개 후보 제시 → 1개 선택 (AI 1회)
7. 조립 → 렌더링
8. Selenium 측정 → overflow 발견           4. 텍스트 편집 (예산 포함, AI 5회)
9. 트림 → 재편집 → 재측정                  5. 렌더링 1회 + Selenium 검증
10. Kei 최종 리뷰                         6. 수학적 조정 (overflow 시, AI 없음)
                                          7. 비전 모델 품질 게이트

API 호출: ~25회                           API 호출: ~8회
Selenium: ~17회                           Selenium: ~2회
소요: ~40분                               소요: ~8-12분
```

---

## 실행 스텝 상세

### Q-1: catalog.yaml에 블록 메타데이터 보강

**현재 catalog.yaml 구조:**
```yaml
- id: venn-diagram
  height_cost: large
  when: "관계, 포함, 교집합"
  not_for: "순서, 흐름"
```

**추가할 필드:**
```yaml
- id: venn-diagram
  height_cost: large
  min_height_px: 150          # ★ 최소 생존 크기
  relation_types:             # ★ 적합한 관계 유형
    - hierarchy
    - inclusion
  category: visuals           # ★ 블록 카테고리 (명시적)
  max_items: 5                # ★ 최대 항목 수
  min_items: 2                # ★ 최소 항목 수
  when: "관계, 포함, 교집합"
  not_for: "순서, 흐름"
```

**작업 내용:**
- 38개 블록 전체에 `min_height_px`, `relation_types`, `category`, `max_items`, `min_items` 추가
- `min_height_px`는 Selenium 실측으로 검증 (최소 콘텐츠로 렌더링하여 측정)
- **파일:** `templates/catalog.yaml`
- **의존성:** 없음
- **소요:** 2시간

---

### Q-2: relation_type → 블록 카테고리 매핑 엔진

**구현:**
```python
# src/block_selector.py (신규)

RELATION_TO_CATEGORIES: dict[str, list[str]] = {
    "hierarchy":    ["visuals"],              # venn, circle, keyword-circle
    "inclusion":    ["visuals"],              # venn
    "comparison":   ["tables", "emphasis"],   # compare-2col-split, comparison-2col
    "sequence":     ["visuals"],              # process-horizontal, flow-arrow
    "cause_effect": ["emphasis"],             # callout-warning, callout-solution
    "definition":   ["cards"],               # card-numbered, card-icon-desc
    "none":         ["emphasis", "cards"],    # dark-bullet-list, quote-big-mark
}

def select_block_candidates(
    topic: dict,
    container_spec: ContainerSpec,
    catalog: dict,
    used_blocks: set[str],    # 슬라이드 내 이미 사용된 블록
) -> list[dict]:
    """결정론적으로 블록 후보를 필터링한다. AI 호출 없음."""

    relation = topic.get("relation_type", "none")
    categories = RELATION_TO_CATEGORIES.get(relation, ["emphasis", "cards"])

    per_topic_px = container_spec.height_px // max(1, len(container_spec.topic_ids))
    candidates = []

    for block in catalog["blocks"]:
        # 1. 카테고리 필터
        if block["category"] not in categories:
            continue

        # 2. 최소 크기 필터
        if block["min_height_px"] > per_topic_px:
            continue

        # 3. height_cost 필터
        if HEIGHT_COST_RANK[block["height_cost"]] > HEIGHT_COST_RANK[container_spec.max_height_cost]:
            continue

        # 4. sidebar 시각 블록 제한
        if container_spec.zone == "sidebar" and block["category"] == "visuals":
            continue

        # 5. 중복 사용 제한
        if block["id"] in used_blocks:
            continue

        candidates.append(block)

    return candidates  # 보통 2-4개
```

- **파일:** 신규 `src/block_selector.py`
- **의존성:** Q-1 (catalog 메타데이터)
- **소요:** 3시간

---

### Q-3: 글자수 예산 계산 엔진

**구현:**
```python
# src/space_allocator.py에 추가

def calculate_char_budget(
    block_type: str,
    container_spec: ContainerSpec,
    catalog: dict,
) -> dict:
    """블록이 컨테이너에서 수용 가능한 최대 글자수를 계산한다."""
    block_def = catalog["blocks"][block_type]
    per_topic_px = container_spec.height_px // max(1, len(container_spec.topic_ids))

    # 폰트 크기 결정 (컨테이너 크기에 따라)
    font_size = _select_font_size(per_topic_px)

    # 구조적 오버헤드 (제목, 패딩, 간격)
    structural = _estimate_structural_overhead(block_type, font_size)
    content_height = per_topic_px - structural

    # 한국어 줄당 글자수
    chars_per_line = int(container_spec.width_px * 0.85 / font_size)
    line_height_px = font_size * 1.6  # 한국어 line-height
    available_lines = max(1, int(content_height / line_height_px))

    # 항목 수 제한
    max_items_by_space = max(1, available_lines // 2)  # 항목당 최소 2줄
    max_items = min(max_items_by_space, block_def.get("max_items", 10))

    return {
        "total_chars": available_lines * chars_per_line,
        "max_items": max_items,
        "chars_per_item": (available_lines * chars_per_line) // max(1, max_items),
        "font_size_px": font_size,
        "available_lines": available_lines,
    }

def _select_font_size(container_height_px: int) -> float:
    """컨테이너 높이에 따른 적정 폰트 크기."""
    if container_height_px >= 300:
        return 15.0
    elif container_height_px >= 150:
        return 13.0
    elif container_height_px >= 80:
        return 12.0
    else:
        return 10.0
```

- **파일:** `src/space_allocator.py`
- **의존성:** Q-1 (catalog 메타데이터)
- **소요:** 2시간

---

### Q-4: Kei 블록 선택 프롬프트 재설계

**현재:** FAISS 2개 + Opus 1개 = 3후보를 15개 렌더링 후 스크린샷 비교
**변경:** 코드가 필터링한 2-3개 후보를 Kei에게 제시, 1개 선택 (AI 1회)

```python
# src/kei_client.py에 추가

BLOCK_SELECTION_PROMPT = """
다음 topic에 가장 적합한 블록을 1개 선택하세요.

## Topic 정보
- 제목: {title}
- 목적: {purpose}
- 관계 유형: {relation_type}
- 핵심 콘텐츠 요약: {summary}

## 컨테이너 제약
- 영역: {zone} ({role}, 비중 {weight}%)
- 높이: {height_px}px, 너비: {width_px}px

## 후보 블록 (모두 이 컨테이너에 물리적으로 들어감)
{candidates_description}

## 선택 기준
1. 콘텐츠의 관계 유형({relation_type})을 가장 잘 시각화하는 블록
2. 이 topic의 목적({purpose})에 가장 부합하는 표현 방식
3. 글자수 예산 내에서 의미 전달이 가능한 블록

## 출력 (JSON)
{{"selected_block": "블록 id", "reason": "선택 근거 1문장"}}
"""
```

- **파일:** `src/kei_client.py`
- **의존성:** Q-2 (후보 필터링), Q-3 (예산 계산)
- **소요:** 2시간

---

### Q-5: pipeline.py 재구성 — Phase P 로직 교체

**핵심 변경:** Phase P의 15-render 루프를 제거하고 Q-2/Q-3/Q-4 기반 단일 경로로 교체.

```python
# pipeline.py 변경 개요

# Phase P 관련 코드 제거:
#   - search_candidates_per_topic() 호출
#   - _opus_batch_recommend() 호출
#   - fill_candidates() 15회 호출
#   - render_block_in_container() 15회 호출
#   - measure_candidate_block() 15회 호출
#   - select_best_candidate() 호출

# Phase Q 코드 추가:
async def generate_slide(...):
    # Step 1-2: 동일 (Kei 분석 + 컨테이너 계산)

    # Step 3: 블록 선택 (Phase Q)
    yield {"event": "progress", "data": "2/5 블록 선택 중..."}
    used_blocks = set()
    for topic in topics:
        # Q-2: 결정론적 후보 필터링
        candidates = select_block_candidates(topic, container_spec, catalog, used_blocks)

        # Q-3: 각 후보의 글자수 예산 계산
        for c in candidates:
            c["budget"] = calculate_char_budget(c["id"], container_spec, catalog)

        # Q-4: Kei 1회 호출로 최종 선택
        selected = await _retry_kei(select_block_for_topic, topic, candidates, container_spec)
        used_blocks.add(selected["block_id"])

    # Step 4: 텍스트 편집 (예산 포함)
    yield {"event": "progress", "data": "3/5 텍스트 편집 중..."}
    # fill_content()에 budget 전달

    # Step 5: 렌더링 1회 + 검증
    yield {"event": "progress", "data": "4/5 렌더링 + 검증 중..."}
    html = render_slide(layout_concept)
    measurement = measure_rendered_heights(html)

    # Step 6: overflow 시 수학적 조정
    if has_overflow(measurement):
        html = apply_glue_compression(html, measurement)  # AI 없음
        # 그래도 overflow면 font-size 축소 (이진 탐색)
        # 그래도 안 되면 해당 블록 텍스트 압축 (AI 1회)

    # Step 7: 비전 모델 품질 게이트
    yield {"event": "progress", "data": "5/5 품질 검증 중..."}
    screenshot = capture_slide_screenshot(html)
    quality = await vision_quality_gate(screenshot, analysis)
    if not quality["passed"]:
        # 문제 블록만 교정 → 재렌더링 (최대 2회)
```

- **파일:** `src/pipeline.py`
- **의존성:** Q-2, Q-3, Q-4
- **소요:** 4시간

---

### Q-6: 비전 모델 품질 게이트

**VASCAR 논문 기반 — 렌더링 → 스크린샷 → 비전 모델 평가 → 교정**

```python
# src/kei_client.py에 추가

VISION_QUALITY_PROMPT = """
이 슬라이드 스크린샷을 평가하세요.

## 체크리스트
1. 모든 텍스트가 컨테이너 안에 있는가? (겹침/잘림 없음)
2. 본심 영역(60%)이 시각적으로 가장 두드러지는가?
3. 각 블록의 폰트가 읽을 수 있는 크기인가? (최소 10px)
4. 블록 유형에 다양성이 있는가? (같은 블록 반복 아닌가)
5. 한국어 비즈니스 프레젠테이션으로서 적절한가?

## 출력 (JSON)
{
  "passed": true/false,
  "score": 0-100,
  "issues": ["문제 설명"],
  "fix_targets": [{"area": "body", "block_index": 0, "action": "shrink|replace|rewrite"}]
}
"""
```

- **파일:** `src/kei_client.py`
- **의존성:** Q-5 (파이프라인 통합)
- **소요:** 2시간

---

### Q-7: overflow 수학적 조정 (LaTeX 글루 모델)

**AI 없이 코드만으로 overflow를 흡수하는 메커니즘.**

```python
# src/space_allocator.py에 추가

@dataclass
class GlueSpec:
    """LaTeX 글루 모델 — 유연한 간격."""
    natural: float   # 기본 간격 (px)
    stretch: float   # 늘어날 수 있는 양 (px)
    shrink: float    # 줄어들 수 있는 양 (px)

SPACING_GLUE = {
    "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),
    "padding":     GlueSpec(natural=16, stretch=0, shrink=8),
}

def apply_glue_compression(html: str, measurement: dict) -> str:
    """overflow 시 간격을 축소하여 흡수한다. AI 호출 없음."""
    for container_name, data in measurement["containers"].items():
        if not data["overflowed"]:
            continue

        excess = data["excess_px"]
        total_shrinkable = sum(g.shrink for g in SPACING_GLUE.values()) * len(data["blocks"])

        if excess <= total_shrinkable:
            # 간격 축소로 해결 가능
            ratio = excess / total_shrinkable
            # CSS 변수 오버라이드 삽입
            html = inject_compressed_spacing(html, container_name, ratio)
        else:
            # 간격만으로 불충분 → 폰트 축소 시도
            html = try_font_reduction(html, container_name, excess - total_shrinkable)

    return html
```

- **파일:** `src/space_allocator.py`
- **의존성:** 없음
- **소요:** 3시간

---

### Q-8: 출력 차단 정책

**overflow 상태에서 결과를 내보내지 않는 안전장치.**

```python
# src/pipeline.py에 추가

class SlideQualityError(Exception):
    """슬라이드 품질이 최소 기준 미달."""

def validate_output(measurement: dict, quality_check: dict) -> None:
    """최종 출력 전 품질 검증. 미달 시 예외 발생."""

    # 1. 물리적 겹침 검사
    for name, container in measurement["containers"].items():
        if container["overflowed"] and container["excess_px"] > 10:
            raise SlideQualityError(
                f"컨테이너 '{name}'에서 {container['excess_px']}px overflow 미해결"
            )

    # 2. 비전 모델 점수 검사
    if quality_check.get("score", 0) < 40:
        raise SlideQualityError(
            f"비전 품질 점수 {quality_check['score']}/100 — 최소 40점 미달"
        )
```

- **파일:** `src/pipeline.py`
- **의존성:** Q-6 (품질 게이트)
- **소요:** 1시간

---

## 태스크 요약

| 스텝 | 내용 | 유형 | 파일 | 의존성 | 소요 |
|------|------|------|------|--------|------|
| Q-1 | catalog.yaml 메타데이터 보강 | 데이터 | `templates/catalog.yaml` | 없음 | 2h |
| Q-2 | relation_type → 블록 카테고리 매핑 | 신규 코드 | `src/block_selector.py` | Q-1 | 3h |
| Q-3 | 글자수 예산 계산 엔진 | 코드 추가 | `src/space_allocator.py` | Q-1 | 2h |
| Q-4 | Kei 블록 선택 프롬프트 재설계 | 코드 수정 | `src/kei_client.py` | Q-2, Q-3 | 2h |
| Q-5 | pipeline.py 재구성 (Phase P → Q) | 코드 수정 | `src/pipeline.py` | Q-2, Q-3, Q-4 | 4h |
| Q-6 | 비전 모델 품질 게이트 | 신규 코드 | `src/kei_client.py` | Q-5 | 2h |
| Q-7 | overflow 수학적 조정 (글루 모델) | 코드 추가 | `src/space_allocator.py` | 없음 | 3h |
| Q-8 | 출력 차단 정책 | 코드 추가 | `src/pipeline.py` | Q-6 | 1h |

**의존 관계:**
```
Q-1 (catalog) ──┬──→ Q-2 (블록 필터) ──┐
                └──→ Q-3 (예산 계산) ──┼──→ Q-4 (Kei 선택) ──→ Q-5 (파이프라인) ──→ Q-6 (품질 게이트) ──→ Q-8 (출력 차단)
                                       │
Q-7 (글루 모델) ←──────────────────────┘ (독립)
```

**총 소요:** ~19시간 (병렬 작업 시 ~12시간)

---

## 기대 효과

| 지표 | Phase P (현재) | Phase Q (목표) |
|------|---------------|---------------|
| 슬라이드 품질 | 20/100 | 70-80/100 |
| 처리 시간 | ~40분 | ~8-12분 |
| API 호출 수 | ~25회 | ~8회 |
| Selenium 호출 | ~17회 | ~2회 |
| 유령 블록 | 발생 (5건) | 불가능 (catalog 검증) |
| overflow 출력 | 허용 | 차단 |
| 블록 다양성 | 3/38 사용 | relation_type 기반 자동 분산 |

---

## Phase Q 이후 방향

Phase Q가 70-80점을 달성하면, 80점 이상을 위해:

1. **디자인 참조 DB 구축** — 고품질 슬라이드 레퍼런스 수집 → PPTAgent식 참조 기반 생성
2. **시각 임베딩 FAISS** — 블록 스크린샷을 임베딩하여 시각적 유사도 검색
3. **LayoutPrompter식 동적 예제** — 과거 성공 슬라이드-콘텐츠 쌍을 few-shot으로 활용

이 방향들은 디자인 참조 DB가 축적된 후에 검토.

---

## 참고 자료 (조사 기반)

| 출처 | 적용한 인사이트 |
|------|---------------|
| Beautiful.ai (상용) | AI는 콘텐츠만, 레이아웃은 규칙 엔진 |
| Napkin.ai (상용) | NLP → 관계 유형 → 시각화 유형 자동 매핑 |
| VASCAR (arXiv 2024) | 생성→렌더링→비전 모델 평가→교정, 훈련 불필요 |
| LayoutPrompter (NeurIPS 2023) | 제로 훈련 동적 예제 선택 |
| RALF (CVPR 2024 Oral) | 검색 증강 레이아웃 |
| Atlassian 디자인 시스템 + LLM | CSS 변수 제약 → "10번째 세션 = 1번째 품질" |
| DesignBench (2025) | LLM CSS 공간 추론 한계: 27.1% 정확도 |
| LaTeX Box/Glue 모델 | 유연한 간격으로 overflow 흡수 |
