Files
C.E.L_Slide_test2/docs/history/IMPROVEMENT-PHASE-Q.md
kyeongmin c42e01f060 문서 정리: Phase 히스토리 md를 docs/history/로 이동 + 오래된 테스트/에셋 정리
- 루트의 IMPROVEMENT-PHASE-*.md, PHASE-*.md 등 45개 → docs/history/로 이동
- docs/block-tests/ 오래된 블록 테스트 HTML 삭제 (figma_to_html_agent로 대체)
- docs/figma-analysis/, docs/figma-assets/, docs/figma-screenshots/ 정리
- docs/test-*.html 등 초기 테스트 파일 정리
- 참고 페이지/ 스크린샷 정리

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 10:56:23 +09:00

532 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 흡수 |