"""Phase Q-2: 제약 기반 블록 선택 엔진. relation_type → 블록 카테고리 결정론적 매핑 + 컨테이너 제약 필터링 + catalog 검증. AI에게 불가능한 선택지를 주지 않는다 (Beautiful.ai 원칙). 주요 함수: - select_block_candidates(): topic + 컨테이너 → 물리적으로 가능한 후보 2-4개 - load_catalog(): catalog.yaml 로딩 + 캐싱 """ from __future__ import annotations import logging from pathlib import Path from typing import Any import yaml from src.space_allocator import ContainerSpec, HEIGHT_COST_ORDER logger = logging.getLogger(__name__) CATALOG_PATH = Path("templates/catalog.yaml") _catalog_cache: dict | None = None _catalog_mtime: float = 0.0 # ────────────────────────────────────── # relation_type → 블록 카테고리 매핑 (Napkin.ai 방식) # ────────────────────────────────────── RELATION_TO_CATEGORIES: dict[str, list[str]] = { "hierarchy": ["visuals"], "inclusion": ["visuals"], "comparison": ["tables", "emphasis", "visuals"], "sequence": ["visuals", "cards"], "cause_effect": ["emphasis"], "definition": ["cards"], "none": ["emphasis", "cards"], } # sidebar에 배치할 수 없는 카테고리 SIDEBAR_FORBIDDEN_CATEGORIES = {"visuals", "media"} # 블록이 콘텐츠 형태 변환을 강제하는 경우 (원문 보존도 저하) # key: block_id, value: 강제하는 형태 # 원문이 서술형인데 이 블록이 선택되면 재작성이 불가피 BLOCKS_FORCING_FORMAT_CHANGE = { "quote-question", # question 슬롯 필수 → 서술형 원문을 질문으로 변환 강제 } # zone: full-width-only 블록은 sidebar에 배치 불가 # (catalog.yaml의 zone 필드로도 관리) # ────────────────────────────────────── # catalog.yaml 로딩 (mtime 캐시) # ────────────────────────────────────── def load_catalog() -> dict: """catalog.yaml을 로딩한다. mtime 기반 캐싱.""" global _catalog_cache, _catalog_mtime if not CATALOG_PATH.exists(): logger.error(f"catalog.yaml 미발견: {CATALOG_PATH}") return {"blocks": []} current_mtime = CATALOG_PATH.stat().st_mtime if _catalog_cache is not None and current_mtime == _catalog_mtime: return _catalog_cache with open(CATALOG_PATH, encoding="utf-8") as f: _catalog_cache = yaml.safe_load(f) _catalog_mtime = current_mtime block_count = len(_catalog_cache.get("blocks", [])) logger.info(f"[Q-2] catalog.yaml 로딩: {block_count}개 블록") return _catalog_cache def _get_block_by_id(block_id: str, catalog: dict) -> dict | None: """catalog에서 블록 ID로 검색.""" for block in catalog.get("blocks", []): if block.get("id") == block_id: return block return None # ────────────────────────────────────── # 핵심: 블록 후보 선택 # ────────────────────────────────────── def select_block_candidates( topic: dict[str, Any], container_spec: ContainerSpec, used_blocks: set[str], catalog: dict | None = None, ) -> list[dict]: """topic + 컨테이너 → 물리적으로 가능한 블록 후보를 결정론적으로 필터링. AI 호출 없음. 결과는 보통 2-4개. Args: topic: {"id", "title", "purpose", "relation_type", ...} container_spec: 이 topic이 속한 컨테이너 used_blocks: 슬라이드 내 이미 사용된 블록 ID 집합 catalog: catalog.yaml 딕셔너리 (None이면 자동 로딩) Returns: [{"id": "venn-diagram", "category": "visuals", "min_height_px": 300, ...}, ...] """ if catalog is None: catalog = load_catalog() relation_type = topic.get("relation_type", "none") categories = RELATION_TO_CATEGORIES.get(relation_type, ["emphasis", "cards"]) # topic당 가용 높이 topic_count_in_container = max(1, len(container_spec.topic_ids)) per_topic_px = container_spec.height_px // topic_count_in_container candidates = [] for block in catalog.get("blocks", []): block_id = block.get("id", "") block_category = block.get("category", "") # ── 필터 1: 카테고리 매칭 ── if block_category not in categories: continue # ── 필터 2: headers 제외 (headers는 슬라이드 제목용) ── if block_category == "headers": continue # ── 필터 3: 최소 생존 크기 (10% tolerance) ── # 7px 차이로 가장 적합한 블록이 탈락하는 것을 방지 min_height = block.get("min_height_px", 0) min_height_with_tolerance = min_height * 0.9 if min_height_with_tolerance > per_topic_px: continue # ── 필터 4: height_cost 범위 ── block_cost = block.get("height_cost", "medium") if HEIGHT_COST_ORDER.get(block_cost, 1) > HEIGHT_COST_ORDER.get(container_spec.max_height_cost, 3): continue # ── 필터 5: sidebar 제한 ── if container_spec.zone == "sidebar": if block_category in SIDEBAR_FORBIDDEN_CATEGORIES: continue if block.get("zone") == "full-width-only": continue # ── 필터 6: full-width-only 블록은 body/sidebar 나뉜 프리셋에서 body에만 ── if block.get("zone") == "full-width-only" and container_spec.zone == "sidebar": continue # ── 필터 7: 중복 사용 제한 ── if block_id in used_blocks: continue # ── 필터 8 (Phase Q fix): 형태 변환 강제 블록 제외 ── # 원문 보존이 중요하므로, 콘텐츠 형태를 강제로 바꾸는 블록은 제외 if block_id in BLOCKS_FORCING_FORMAT_CHANGE: continue # ── 필터 9: relation_types 명시적 매칭 ── # relation_types가 명시되어 있고 현재 relation_type이 포함 안 되면 제외 # (deprioritize가 아니라 exclude — 의미 왜곡 방지) # 예: process-horizontal(sequence)은 hierarchy 콘텐츠에서 제외 block_relations = block.get("relation_types", []) if block_relations and relation_type not in block_relations: continue # Phase R: 블록에 available variants 정보 첨부 variants = block.get("variants", []) if variants: block["_available_variants"] = variants else: block["_available_variants"] = [{"id": "default", "description": "기본"}] candidates.append(block) logger.info( f"[Q-2] topic {topic.get('id')} (relation={relation_type}, " f"container={per_topic_px}px): {len(candidates)}개 후보 " f"[{', '.join(c['id'] for c in candidates[:5])}]" ) return candidates # ────────────────────────────────────── # 폴백: 카테고리 제한 없이 검색 # ────────────────────────────────────── def select_fallback_candidates( container_spec: ContainerSpec, used_blocks: set[str], catalog: dict | None = None, ) -> list[dict]: """relation_type 매핑에서 후보가 없을 때, 물리적 제약만으로 검색. 최소 크기 + height_cost + zone만 검사. 카테고리 무시. """ if catalog is None: catalog = load_catalog() topic_count = max(1, len(container_spec.topic_ids)) per_topic_px = container_spec.height_px // topic_count candidates = [] for block in catalog.get("blocks", []): block_id = block.get("id", "") if block.get("category") == "headers": continue if block.get("min_height_px", 0) > per_topic_px: continue if HEIGHT_COST_ORDER.get(block.get("height_cost", "medium"), 1) > HEIGHT_COST_ORDER.get(container_spec.max_height_cost, 3): continue if container_spec.zone == "sidebar" and block.get("zone") == "full-width-only": continue if block_id in used_blocks: continue candidates.append(block) # compact 블록 우선 (작은 컨테이너에 적합) cost_order = {"compact": 0, "medium": 1, "large": 2, "xlarge": 3} candidates.sort(key=lambda c: cost_order.get(c.get("height_cost", "medium"), 1)) logger.info( f"[Q-2 fallback] container={per_topic_px}px: {len(candidates)}개 후보" ) return candidates # ────────────────────────────────────── # 후보 설명 텍스트 생성 (Kei 프롬프트용) # ────────────────────────────────────── def format_candidates_for_prompt( candidates: list[dict], budget: dict | None = None, ) -> str: """블록 후보 목록을 Kei 프롬프트에 포함할 텍스트로 포맷. Phase R: variant 정보 포함.""" lines = [] for i, c in enumerate(candidates[:5], 1): # 최대 5개 block_id = c.get("id", "") name = c.get("name", "") visual = c.get("visual", "")[:80] # 80자로 축약 height_cost = c.get("height_cost", "") when = c.get("when", "")[:60] budget_info = "" if budget and block_id in budget: b = budget[block_id] budget_info = f" | 예산: 최대 {b['max_items']}항목, 총 {b['total_chars']}자" # Phase R: variant 정보 variants = c.get("_available_variants", []) variant_lines = "" if len(variants) > 1: # default만 있으면 표시 안 함 v_descs = [f" - {v['id']}: {v.get('description', '')}" for v in variants] variant_lines = "\n 변형:\n" + "\n".join(v_descs) lines.append( f" {i}. {block_id} ({name})\n" f" 시각: {visual}\n" f" 적합: {when}\n" f" 크기: {height_cost}{budget_info}{variant_lines}" ) return "\n".join(lines)