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