문서 정리: 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>
This commit is contained in:
2026-04-13 10:56:23 +09:00
parent d57860578f
commit c42e01f060
206 changed files with 0 additions and 13498 deletions

View File

@@ -0,0 +1,531 @@
# 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 흡수 |