Files
C.E.L_Slide_test2/IMPROVEMENT-PHASE-Q.md
kyeongmin 29f56187c0 Phase P~S 전체 작업물: 검증 스크립트, 블록 템플릿, 설계 문서, 코드 수정
포함 내용:
- Phase P/Q/R/S 설계 문서 (IMPROVEMENT-PHASE-*.md)
- 영역별 검증 스크립트 (scripts/verify_*.py, test_*.py)
- 블록 템플릿 추가 (cards, emphasis 변형)
- 코드 수정: block_search, content_editor, design_director, slide_measurer
- catalog.yaml 블록 목록 업데이트
- CLAUDE.md, PROGRESS.md, README.md 업데이트

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 08:38:06 +09:00

19 KiB
Raw Blame History

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 구조:

- id: venn-diagram
  height_cost: large
  when: "관계, 포함, 교집합"
  not_for: "순서, 흐름"

추가할 필드:

- 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 → 블록 카테고리 매핑 엔진

구현:

# 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: 글자수 예산 계산 엔진

구현:

# 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회)

# 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 기반 단일 경로로 교체.

# 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 논문 기반 — 렌더링 → 스크린샷 → 비전 모델 평가 → 교정

# 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를 흡수하는 메커니즘.

# 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 상태에서 결과를 내보내지 않는 안전장치.

# 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 흡수