"""DA-13a + DA-13b: 2단계 — 디자인 팀장. Step A: 레이아웃 프리셋 선택 (규칙 기반, LLM 불필요) Step B: 프리셋 안에서 블록 매핑 + 글자 수 가이드 (Sonnet) """ from __future__ import annotations import logging from pathlib import Path from typing import Any import httpx import yaml from src.config import settings from src.json_utils import parse_json as _parse_json from src.sse_utils import stream_sse_tokens logger = logging.getLogger(__name__) # ────────────────────────────────────── # 블록별 슬롯 정의 # ────────────────────────────────────── BLOCK_SLOTS = { # headers/ (5개) "section-title-with-bg": { "required": ["title_ko"], "optional": ["title_en", "breadcrumb", "bg_image"], "slot_desc": { "title_ko": "한글 메인 타이틀", "title_en": "영문 서브 타이틀 (없으면 생략)", # [legacy Phase R'/Q example — INTEGRATION-AUDIT-01 §10.4] "breadcrumb": "상위 카테고리 경로 (예: 디지털전환 > BIM)", "bg_image": "배경 이미지 경로", }, }, "section-header-bar": { "required": ["title"], "optional": ["subtitle"], "slot_desc": { "title": "섹션 제목 (짧고 굵게)", "subtitle": "보조 설명 (한 줄)", }, }, "topic-left-right": { "required": ["title", "description"], "optional": [], "slot_desc": { "title": "꼭지 제목 (좌측, 굵게)", "description": "꼭지 설명 (우측, 2~3줄)", }, }, "topic-center": { "required": ["title"], "optional": ["subtitle", "description"], "slot_desc": { "title": "중앙 정렬 대제목", "subtitle": "부제목 (작은 글씨)", "description": "추가 설명 (1~2줄)", }, }, "topic-numbered": { "required": ["number", "title"], "optional": ["description", "color"], "slot_desc": { "number": "순번 (1, 2, 3 등)", "title": "단계/항목 제목", "description": "설명 텍스트", "color": "원형 번호 색상 (CSS 색상값)", }, }, # cards/ (9개) "card-image-3col": { "required": ["cards"], "optional": [], "slot_desc": { "cards": "카드 배열. 각 카드: {image: '이미지 경로', title: '제목', title_en: '영문', bullets: ['항목1', '항목2']}. 3장.", }, }, "card-dark-overlay": { "required": ["cards"], "optional": [], "slot_desc": { "cards": "카드 배열. 각 카드: {image: '배경 이미지', title: '키워드', description: '짧은 설명'}. 3~5장.", }, }, "card-tag-image": { "required": ["cards"], "optional": [], "slot_desc": { "cards": "카드 배열. 각 카드: {tag: '카테고리 라벨', tag_color: '색상', image: '이미지', title: '제목', description: '설명'}. 3장.", }, }, "card-icon-desc": { "required": ["cards"], "optional": [], "slot_desc": { "cards": "카드 배열. 각 카드: {icon: '이모지', title: '제목', description: '설명 (2~3줄)'}. 2~4장.", }, }, "card-compare-3col": { "required": ["cards"], "optional": [], "slot_desc": { "cards": "비교 카드 배열. 각 카드: {header: '카테고리명', header_color: '색상', bullets: ['항목1', '항목2']}. 3장.", }, }, "card-step-vertical": { "required": ["steps"], "optional": [], "slot_desc": { "steps": "단계 배열. 각 단계: {number: '01', title: '단계명', description: '설명', image: '이미지(선택)'}. 3~5단계.", }, }, "card-image-round": { "required": ["cards"], "optional": [], "slot_desc": { "cards": "카드 배열. 각 카드: {image: '원형 이미지', title: '제목', description: '설명'}. 2~3장.", }, }, "card-stat-number": { "required": ["stats"], "optional": [], "slot_desc": { "stats": "통계 배열. 각 항목: {number: '85', unit: '%', label: '비용 절감율'}. 2~4개. 숫자는 출처 있는 것만!", }, }, "card-numbered": { "required": ["items"], "optional": [], "slot_desc": { "items": "항목 배열. 각 항목: {title: '항목 제목', description: '설명'}. 3~5개.", }, }, # tables/ (3개) "compare-3col-badge": { "required": ["headers", "rows"], "optional": [], "slot_desc": { "headers": "3개 열 헤더 배열: ['항목', 'A 대상', 'B 대상']", "rows": "비교 행 배열. 각 행: {criteria: '비교 기준', left: 'A 내용', right: 'B 내용'}. 최소 3행.", }, }, "compare-2col-split": { "required": ["left_title", "right_title", "rows"], "optional": [], "slot_desc": { "left_title": "왼쪽 열 헤더", "right_title": "오른쪽 열 헤더", "rows": "비교 행 배열. 각 행: {criteria: '비교 기준', left: '왼쪽 내용', right: '오른쪽 내용'}. 최소 3행.", }, }, "table-simple-striped": { "required": ["headers", "rows"], "optional": [], "slot_desc": { "headers": "열 헤더 배열: ['열1', '열2', '열3']", "rows": "데이터 행 배열. 각 행: ['셀1', '셀2', '셀3']. 행 수 자유.", }, }, # visuals/ (6개) "venn-diagram": { "required": ["center_label", "items"], "optional": ["center_sub", "description"], "slot_desc": { "center_label": "중앙 교집합 라벨 (핵심 키워드)", "items": "원 배열. 각 원: {label: '영역명', sub: '설명'}. 2~5개.", "center_sub": "중앙 부가 설명", "description": "다이어그램 하단 설명", }, }, "circle-gradient": { "required": ["label"], "optional": ["sub_label"], "slot_desc": { "label": "원 중앙 메인 텍스트 (키워드, 1~2단어)", "sub_label": "원 아래 보조 텍스트", }, }, "compare-pill-pair": { "required": ["left_label", "right_label"], "optional": ["left_sub", "right_sub"], "slot_desc": { "left_label": "왼쪽 개념명 (1~2단어)", "right_label": "오른쪽 개념명 (1~2단어)", "left_sub": "왼쪽 보조 설명", "right_sub": "오른쪽 보조 설명", }, }, "process-horizontal": { "required": ["steps"], "optional": [], "slot_desc": { "steps": "단계 배열. 각 단계: {number: '01', title: '단계명', description: '설명'}. 3~5단계.", }, }, "flow-arrow-horizontal": { "required": ["steps"], "optional": [], "slot_desc": { "steps": "흐름 배열. 각 항목: {label: '단계명'}. 3~5개. 화살표로 연결됨.", }, }, "keyword-circle-row": { "required": ["keywords"], "optional": [], "slot_desc": { "keywords": "키워드 배열. 각 항목: {letter: '약어 (G)', label: '풀네임', description: '설명'}. 3~5개.", }, }, # emphasis/ (10개) "quote-big-mark": { "required": ["quote_text"], "optional": ["source"], "slot_desc": { "quote_text": "인용할 본문 텍스트 (핵심 발언, 1~3문장)", "source": "출처 (예: 국토교통부, 2024). 꼭지 제목이 아님!", }, }, "quote-question": { "required": ["question"], "optional": ["description"], "slot_desc": { "question": "독자에게 던지는 질문 (1문장, 물음표로 끝)", "description": "질문에 대한 부연 (1~2줄)", }, }, "comparison-2col": { "required": ["left_title", "left_content", "right_title", "right_content"], "optional": ["left_subtitle", "right_subtitle"], "slot_desc": { "left_title": "왼쪽 개념 제목 (파란색)", "left_content": "왼쪽 본문 (불릿 또는 문장)", "right_title": "오른쪽 개념 제목 (빨간색)", "right_content": "오른쪽 본문 (불릿 또는 문장)", "left_subtitle": "왼쪽 보조 제목", "right_subtitle": "오른쪽 보조 제목", }, }, "banner-gradient": { "required": ["text"], "optional": ["sub_text"], "slot_desc": { "text": "핵심 결론 한 줄 (굵은 대형 텍스트. 가장 중요한 메시지)", "sub_text": "부연 설명 (작은 보조 텍스트. text보다 덜 중요)", }, }, "dark-bullet-list": { "required": ["bullets"], "optional": ["title"], "slot_desc": { "title": "리스트 상단 제목 (파란색, 선택)", "bullets": "불릿 항목 배열: ['핵심 포인트 1', '핵심 포인트 2']. 3~5개.", }, }, "highlight-strip": { "required": ["segments"], "optional": [], "slot_desc": { "segments": "색상 구간 배열. 각 구간: {label: '카테고리명', color: '색상'}. 3~5개.", }, }, "callout-solution": { "required": ["title", "description"], "optional": ["icon", "source"], "slot_desc": { "title": "솔루션/방향성 제목", "description": "상세 설명 (2~3줄)", "icon": "아이콘 이모지 (예: 💡)", "source": "출처 (있으면)", }, }, "callout-warning": { "required": ["title", "description"], "optional": ["icon"], "slot_desc": { "title": "문제점/경고 제목", "description": "상세 설명 (2~3줄)", "icon": "아이콘 이모지 (예: ⚠️)", }, }, "tab-label-row": { "required": ["tabs"], "optional": [], "slot_desc": { "tabs": "탭 배열. 각 탭: {label: '탭 이름', active: true/false}. 3~5개. 하나만 active.", }, }, "divider-text": { "required": ["text"], "optional": [], "slot_desc": { "text": "구분선 중앙 텍스트 (짧은 전환 문구, 1~5단어)", }, }, # media/ (5개) "image-row-2col": { "required": ["images"], "optional": [], "slot_desc": { "images": "이미지 배열. 각 항목: {src: '이미지 경로', alt: '설명', caption: '캡션'}. 2장.", }, }, "image-grid-2x2": { "required": ["images"], "optional": [], "slot_desc": { "images": "이미지 배열. 각 항목: {src: '이미지 경로', alt: '설명'}. 4장 (2x2).", }, }, "image-side-text": { "required": ["image_src"], "optional": ["image_alt", "title", "description", "bullets"], "slot_desc": { "image_src": "좌측 이미지 경로", "image_alt": "이미지 대체 텍스트", "title": "우측 제목", "description": "우측 설명 텍스트", "bullets": "우측 불릿 항목 배열: ['항목1', '항목2']", }, }, "image-full-caption": { "required": ["src"], "optional": ["alt", "caption"], "slot_desc": { "src": "전체 너비 이미지 경로", "alt": "이미지 대체 텍스트", "caption": "이미지 하단 캡션", }, }, "image-before-after": { "required": ["before_src", "after_src"], "optional": ["before_label", "after_label", "caption"], "slot_desc": { "before_src": "Before 이미지 경로", "after_src": "After 이미지 경로", "before_label": "Before 라벨 (기본: Before)", "after_label": "After 라벨 (기본: After)", "caption": "비교 설명 캡션", }, }, } # ────────────────────────────────────── # 슬라이드 물리적 제약 # ────────────────────────────────────── # 프레임: 1280×720px, 패딩 40px×4 → 가용 1200×640px # grid gap: 20px, header ~50px, footer ~60px # → 본문 zone 가용 높이 ≈ 490px (640 - 50 - 20 - 60 - 20) FRAME_AVAILABLE_HEIGHT = 490 # ────────────────────────────────────── # 레이아웃 프리셋 정의 # zone별 budget_px = 해당 zone에 넣을 수 있는 최대 높이 # width_pct = zone의 가로 비율 (블록 선택 시 참고) # ────────────────────────────────────── LAYOUT_PRESETS = { "sidebar-right": { "description": "좌측 본문 흐름 + 우측 참조 사이드바", "grid_areas": "'header header' 'body sidebar' 'footer footer'", "grid_columns": "65fr 35fr", "grid_rows": "auto 1fr auto", "zones": { "header": {"desc": "슬라이드 제목. 자동 크기.", "budget_px": 50, "width_pct": 100}, "body": {"desc": "flow 꼭지 배치 (위→아래 순서).", "budget_px": 490, "width_pct": 65}, "sidebar": {"desc": "reference 꼭지. 좁으므로 card-grid 1열, 시각화 블록 금지.", "budget_px": 490, "width_pct": 35}, "footer": {"desc": "결론 꼭지. 전체 너비.", "budget_px": 60, "width_pct": 100}, }, }, "two-column": { "description": "대등한 2단 비교", "grid_areas": "'header header' 'left right' 'footer footer'", "grid_columns": "1fr 1fr", "grid_rows": "auto 1fr auto", "zones": { "header": {"desc": "슬라이드 제목. 자동 크기.", "budget_px": 50, "width_pct": 100}, "left": {"desc": "첫 번째 비교 대상.", "budget_px": 490, "width_pct": 50}, "right": {"desc": "두 번째 비교 대상.", "budget_px": 490, "width_pct": 50}, "footer": {"desc": "결론 꼭지.", "budget_px": 60, "width_pct": 100}, }, }, "hero-detail": { "description": "고강조 1개 + 보조 상세", "grid_areas": "'header header' 'hero hero' 'detail detail' 'footer footer'", "grid_columns": "1fr 1fr", "grid_rows": "auto 2fr 1fr auto", "zones": { "header": {"desc": "슬라이드 제목. 자동 크기.", "budget_px": 50, "width_pct": 100}, "hero": {"desc": "고강조 꼭지 (크게).", "budget_px": 310, "width_pct": 100}, "detail": {"desc": "나머지 보조 꼭지.", "budget_px": 155, "width_pct": 100}, "footer": {"desc": "결론 꼭지.", "budget_px": 60, "width_pct": 100}, }, }, "single-column": { "description": "단일 컬럼 순차 배치", "grid_areas": "'header' 'body' 'footer'", "grid_columns": "1fr", "grid_rows": "auto 1fr auto", "zones": { "header": {"desc": "슬라이드 제목. 자동 크기.", "budget_px": 50, "width_pct": 100}, "body": {"desc": "모든 꼭지 위→아래 순서.", "budget_px": 490, "width_pct": 100}, "footer": {"desc": "결론 꼭지.", "budget_px": 60, "width_pct": 100}, }, }, } # ────────────────────────────────────── # Step A: 프리셋 선택 (규칙 기반) # ────────────────────────────────────── def select_preset(analysis: dict[str, Any]) -> str: """실장의 role 분석을 보고 레이아웃 프리셋을 자동 선택한다. LLM 호출 불필요. 규칙 기반. """ topics = analysis.get("topics", []) has_reference = any( t.get("role") == "reference" for t in topics ) flow_topics = [t for t in topics if t.get("role", "flow") == "flow"] high_emphasis = [t for t in flow_topics if t.get("emphasis")] # reference 꼭지가 있으면 sidebar if has_reference: preset = "sidebar-right" # flow 꼭지가 정확히 2개이고 대등 비교이면 two-column elif ( len(flow_topics) == 2 and all(t.get("layer") == "core" for t in flow_topics) ): preset = "two-column" # 고강조 1개 + 나머지가 보조이면 hero elif ( len(high_emphasis) == 1 and len(flow_topics) >= 3 ): preset = "hero-detail" # 기본: single-column else: preset = "single-column" logger.info(f"[Step A] 프리셋 선택: {preset}") return preset # ────────────────────────────────────── # Step B: 프리셋 내 블록 매핑 (Sonnet) # ────────────────────────────────────── def _get_registered_block_ids() -> set[str]: """catalog.yaml에 등록된 블록 ID 집합을 반환한다.""" catalog_path = Path(__file__).parent.parent / "templates" / "catalog.yaml" if not catalog_path.exists(): return set(BLOCK_SLOTS.keys()) try: with open(catalog_path, encoding="utf-8") as f: data = yaml.safe_load(f) return { b["id"] for b in data.get("blocks", []) if b.get("id") and not b.get("id", "").replace("-", "").isdigit() } except Exception: return set(BLOCK_SLOTS.keys()) def _load_catalog() -> str: """catalog.yaml 로드.""" catalog_path = Path(__file__).parent.parent / "templates" / "catalog.yaml" if catalog_path.exists(): return catalog_path.read_text(encoding="utf-8") return """사용 가능한 블록: - quote-question: 질문형 강조. 문제 제기, 전환점. - compare-pill-pair: 2개 키워드 시각 대비. - comparison-2col: 2항목 비교. - card-icon-desc: 아이콘+설명 카드. - card-dark-overlay: 다크 배경 키워드 카드. - venn-diagram: 벤 다이어그램. 포함/상위-하위 관계. - process-horizontal: 단계 흐름. 절차. - topic-left-right: 꼭지 제목+설명. - banner-gradient: 섹션 강조 배너.""" # Step B(Sonnet) 제거됨 — Phase O에서 Kei 확정 + 코드 검증으로 대체. # STEP_B_PROMPT, _fallback_layout, PURPOSE_FALLBACK, DOWNGRADE_MAP, _downgrade_fallback 삭제. # Step B(Sonnet) 제거됨 — Phase O에서 Kei 확정 + 코드 검증으로 대체. async def _opus_batch_recommend( analysis: dict[str, Any], faiss_candidates: dict[int, list[dict]], container_specs: dict | None = None, ) -> dict[int, str]: """Phase P: 전체 topic을 한꺼번에 보여주고 topic별 Opus 추천 1개씩 받는다. FAISS 후보 2개를 함께 보여주고, Opus가 도메인 지식으로 다른 1개를 추천. 1회 Kei API 호출로 전체 topic 처리. Returns: {topic_id: block_type} — 각 topic별 Opus 추천 블록 """ import httpx from src.sse_utils import stream_sse_tokens kei_url = getattr(settings, "kei_api_url", "http://localhost:8000") # 각 topic의 정보 + FAISS 후보 정리 topic_sections = [] for topic in analysis.get("topics", []): tid = topic.get("id") faiss_blocks = faiss_candidates.get(tid, []) faiss_ids = [b["id"] for b in faiss_blocks] # 컨테이너 제약 정보 container_info = "" if container_specs: from src.space_allocator import find_container_for_topic spec = find_container_for_topic(tid, container_specs) if spec: per_topic = spec.height_px // max(1, len(spec.topic_ids)) container_info = f"컨테이너: {per_topic}px, 허용 height_cost: {spec.max_height_cost} 이하" topic_sections.append( f"- 꼭지 {tid}: {topic.get('title', '')}\n" f" purpose: {topic.get('purpose', '')}\n" f" relation_type: {topic.get('relation_type', '')}\n" f" expression_hint: {topic.get('expression_hint', '')}\n" f" FAISS 후보: {faiss_ids}\n" f" {container_info}" ) prompt = ( "아래 각 꼭지에 대해 FAISS가 추천한 블록 2개를 참고하되,\n" "도메인 지식을 활용하여 **FAISS 후보에 없는 다른 블록 1개**를 추천해줘.\n" "FAISS 후보와 중복되면 안 된다.\n" "각 꼭지의 purpose, relation_type, expression_hint를 보고\n" "**콘텐츠의 의미와 목적에 가장 적합한** 블록을 추천하라.\n" "컨테이너 크기 제약도 반드시 고려하라.\n\n" f"## 꼭지 목록\n" + "\n".join(topic_sections) + "\n\n## 출력 (JSON만)\n" '{"recommendations": [{"topic_id": 1, "block_type": "...", "reason": "..."}]}' ) try: async with httpx.AsyncClient(timeout=None) as client: async with client.stream( "POST", f"{kei_url}/api/direct", json={ "message": prompt, }, timeout=None, ) as response: if response.status_code != 200: logger.warning(f"[Phase P] Opus 배치 추천 HTTP {response.status_code}") return {} full_text = await stream_sse_tokens(response) if not full_text: return {} result = _parse_json(full_text) if result and "recommendations" in result: mapping = {} for rec in result["recommendations"]: tid = rec.get("topic_id") or rec.get("id") if tid is not None: mapping[tid] = rec.get("block_type", "") logger.info(f"[Phase P] Opus 배치 추천: {mapping}") return mapping logger.warning(f"[Phase P] Opus 배치 추천 JSON 파싱 실패: {full_text[:200]}") return {} except Exception as e: logger.warning(f"[Phase P] Opus 배치 추천 실패: {e}") return {} async def _opus_block_recommendation( analysis: dict[str, Any], block_candidates: str, preset_name: str, preset: dict[str, Any], container_specs: dict | None = None, ) -> dict[str, Any] | None: """Phase O: Kei(Opus)가 컨테이너 제약을 보고 블록을 확정한다. Kei API를 통해 Opus가 사고하여: - 컨테이너 크기(px)에 맞는 블록 선정 - height_cost가 컨테이너보다 큰 블록은 선택 금지 - 도메인 지식 기반 판단 반드시 Kei API 경유. Anthropic 직접 호출 절대 금지. """ import httpx kei_url = getattr(settings, "kei_api_url", "http://localhost:8000") zone_desc = "\n".join( f"- {name}: {z['desc']} [높이: ~{z['budget_px']}px, 너비: {z['width_pct']}%]" for name, z in preset["zones"].items() ) topics_text = "\n".join( f"- 꼭지 {t.get('id', '?')}: {t.get('title', '')} " f"[{t.get('layer', '?')}, {t.get('role', 'flow')}, 강조:{t.get('emphasis', False)}]" for t in analysis.get("topics", []) ) # Phase O: 컨테이너 제약 텍스트 container_text = "" if container_specs: from src.space_allocator import ContainerSpec lines = ["## 컨테이너 제약 (반드시 준수)\n각 꼭지는 아래 컨테이너 안에 들어가야 한다. height_cost가 허용 범위를 초과하면 선택 금지.\n"] for role, spec in container_specs.items(): for tid in spec.topic_ids: lines.append( f"- 꼭지 {tid}: 컨테이너 {spec.height_px}px × {spec.width_px}px, " f"허용 height_cost: **{spec.max_height_cost} 이하**, " f"최대 항목 수: {spec.block_constraints.get('max_items', '?')}개" ) container_text = "\n".join(lines) + "\n\n" prompt = ( f"슬라이드 디자인 블록 추천을 해줘.\n\n" f"## 프리셋: {preset_name}\n{preset['description']}\n\n" f"## Zone 구조 (반드시 이 zone에 배정하라)\n{zone_desc}\n\n" f"## Zone 배정 규칙 (절대 규칙)\n" f"- flow 꼭지 → body / left / hero zone\n" f"- reference 꼭지 → sidebar zone\n" f"- conclusion 꼭지 → **반드시 footer zone** (banner-gradient 권장)\n" f"- sidebar(35%)에는 시각화 블록 금지\n\n" f"{container_text}" f"## 꼭지 목록\n{topics_text}\n\n" f"## 블록 후보 (FAISS 검색 결과)\n{block_candidates}\n\n" f"## 요청\n" f"각 꼭지에 가장 적합한 블록을 추천해줘.\n" f"컨테이너 높이(px)와 허용 height_cost를 반드시 확인하고,\n" f"도메인 지식을 활용하여 콘텐츠 성격에 맞는 블록을 선택해.\n\n" f"## 출력 형식 (JSON만)\n" f'{{"recommendations": [' f'{{"topic_id": 1, "block_type": "...", "area": "...", ' f'"reason": "도메인 관점에서 이 블록이 적합한 이유", ' f'"size_guide": "compact|medium|large"}}]}}' ) try: async with httpx.AsyncClient(timeout=None) as client: async with client.stream( "POST", f"{kei_url}/api/direct", json={ "message": prompt, }, timeout=None, ) as response: if response.status_code != 200: logger.warning(f"[Step A-2] Kei API HTTP {response.status_code}") return None full_text = await stream_sse_tokens(response) if not full_text: logger.warning("[Step A-2] Kei API 응답 텍스트 없음") return None result = _parse_json(full_text) if result and "recommendations" in result: logger.info( f"[Step A-2] Opus 블록 추천 완료: " f"{len(result['recommendations'])}개" ) return result logger.warning(f"[Step A-2] JSON 파싱 실패: {full_text[:200]}") return None except Exception as e: logger.warning(f"[Step A-2] Kei API 호출 실패: {e}") return None async def create_layout_concept( content: str, analysis: dict[str, Any], container_specs: dict | None = None, ) -> dict[str, Any]: """2단계: Step A(프리셋) + Step B(블록 매핑). Args: content: 원본 텍스트 analysis: 1단계 실장의 꼭지 분석 결과 Returns: 레이아웃 컨셉 JSON """ # Step A: 프리셋 선택 (규칙 기반) preset_name = select_preset(analysis) preset = LAYOUT_PRESETS[preset_name] # P2-A: FAISS 검색으로 관련 블록만 추출 from src.block_search import search_blocks_for_topics topics = analysis.get("topics", []) catalog_text = search_blocks_for_topics(topics, top_k_per_topic=3, total_max=10) logger.info(f"[Step A] 블록 후보 검색 완료 (FAISS)") # Phase N-1: Step A-2 — Kei(Opus)가 블록 확정. Sonnet은 zone + char_guide만. opus_recommendation = await _opus_block_recommendation( analysis, catalog_text, preset_name, preset, container_specs=container_specs, ) # Kei 확정 블록 매핑 (topic_id → block_type) kei_confirmed_blocks: dict[int, str] = {} kei_confirmed_areas: dict[int, str] = {} if opus_recommendation and opus_recommendation.get("recommendations"): recs = opus_recommendation["recommendations"] for rec in recs: # Kei가 topic_id 또는 id로 응답할 수 있으므로 양쪽 체크 tid = rec.get("topic_id") or rec.get("id") if tid is not None: kei_confirmed_blocks[tid] = rec.get("block_type", "") kei_confirmed_areas[tid] = rec.get("area", "") logger.info(f"[Step A-2] Kei 블록 확정: {kei_confirmed_blocks}") else: # Kei API 필수. 응답 없으면 성공할 때까지 무한 재시도. import asyncio RETRY_INTERVAL = 10 attempt = 0 while not opus_recommendation or not opus_recommendation.get("recommendations"): attempt += 1 logger.warning(f"[Step A-2] Kei API 응답 없음 (시도 {attempt}). {RETRY_INTERVAL}초 후 재시도...") await asyncio.sleep(RETRY_INTERVAL) opus_recommendation = await _opus_block_recommendation( analysis, catalog_text, preset_name, preset ) # 재시도 성공 → 확정 블록 매핑 for rec in opus_recommendation["recommendations"]: tid = rec.get("topic_id") or rec.get("id") if tid is not None: kei_confirmed_blocks[tid] = rec.get("block_type", "") kei_confirmed_areas[tid] = rec.get("area", "") logger.info(f"[Step A-2] Kei 블록 확정 (재시도 후): {kei_confirmed_blocks}") # Phase O: Kei 확정 블록 + 코드 검증으로 직접 layout_concept 생성 # Step B(Sonnet) 제거됨 — Kei가 블록/zone을 확정, 코드가 스펙 계산 blocks = [] registered_ids = _get_registered_block_ids() valid_zones = {z for z in preset["zones"] if z != "header"} default_zone = "body" if "body" in valid_zones else next(iter(valid_zones)) for topic in topics: tid = topic.get("id") role = topic.get("role", "flow") # 블록 타입: Kei 확정값 block_type = kei_confirmed_blocks.get(tid, "topic-left-right") # 블록 ID 검증: catalog에 없으면 에러 로그 (fallback 없음) if block_type not in registered_ids: logger.error(f"[블록 검증] Kei 확정 블록 '{block_type}'이 catalog에 없음. topic {tid}") block_type = "topic-left-right" # 최소 안전 블록 # zone 배치: Kei 확정값 → 검증 area = kei_confirmed_areas.get(tid, "") if not area or area not in valid_zones: # Kei가 area를 안 줬으면 role에서 결정 if role == "reference" and "sidebar" in valid_zones: area = "sidebar" elif topic.get("layer") == "conclusion" and "footer" in valid_zones: area = "footer" else: area = default_zone # conclusion 꼭지 → footer 강제 if topic.get("layer") == "conclusion" and "footer" in valid_zones: area = "footer" # body/sidebar 금지 블록 검증 if area in ("body", "left", "right", "hero", "detail") and block_type in BODY_FORBIDDEN_MAP: replacement = BODY_FORBIDDEN_MAP[block_type] if replacement: logger.warning(f"[블록 검증] body 금지 '{block_type}' → '{replacement}'") block_type = replacement else: continue # None이면 삭제 if area == "sidebar" and block_type in SIDEBAR_FORBIDDEN_BLOCKS: replacement = SIDEBAR_FORBIDDEN_BLOCKS[block_type] if replacement: logger.warning(f"[블록 검증] sidebar 금지 '{block_type}' → '{replacement}'") block_type = replacement else: continue blocks.append({ "area": area, "type": block_type, "topic_id": tid, "purpose": topic.get("purpose", ""), "reason": kei_confirmed_blocks.get(tid, ""), "size": "medium", }) # Phase N-2: sidebar에 reference 블록이 있으면 section label 자동 삽입 sidebar_blocks = [b for b in blocks if b.get("area") == "sidebar"] if sidebar_blocks: first_sidebar = sidebar_blocks[0] sidebar_topic = next( (t for t in topics if t.get("id") == first_sidebar.get("topic_id")), None, ) section_title = "" if sidebar_topic: section_title = sidebar_topic.get("section_title", "") if not section_title: purpose = first_sidebar.get("purpose", "") section_title = { "용어정의": "용어 정의", "근거사례": "참고 자료", }.get(purpose, "") if section_title: first_sidebar_idx = next( i for i, b in enumerate(blocks) if b.get("area") == "sidebar" ) blocks.insert(first_sidebar_idx, { "area": "sidebar", "type": "divider-text", "topic_id": None, "purpose": "_label", "data": {"text": section_title}, "size": "compact", "_is_label": True, }) logger.info(f"[N-2] sidebar 섹션 제목 삽입: '{section_title}'") # zone별 height_cost 합산 검증 overflows = _validate_height_budget(blocks, preset) logger.info( f"[레이아웃] 블록 배치 완료: {preset_name}, {len(blocks)}개 블록" + (f", overflow {len(overflows)}건" if overflows else "") ) result = { "title": analysis.get("title", "슬라이드"), "pages": [{ "grid_areas": preset["grid_areas"], "grid_columns": preset["grid_columns"], "grid_rows": preset["grid_rows"], "blocks": blocks, }], } if overflows: result["overflow"] = overflows return result # height_cost → px 변환 (결정론적) HEIGHT_COST_PX = { "compact": 70, "medium": 150, "large": 250, "xlarge": 400, } # body/sidebar/footer zone에서 사용 금지인 블록 → 교체 BODY_FORBIDDEN_MAP = { "section-title-with-bg": "topic-center", # 500px 블록 → compact 헤더로 "section-header-bar": None, # body에서 제거 — header에 이미 slide-title 있음 (J-2) } # Phase M: 블록-zone 적합성 맵 # sidebar(35% 너비)에서 사용 불가한 블록 → 대체 블록 SIDEBAR_FORBIDDEN_BLOCKS = { "card-compare-3col": "card-numbered", "card-dark-overlay": "card-numbered", "card-icon-desc": "card-numbered", "card-image-3col": "card-numbered", "card-image-round": "card-numbered", "card-stat-number": "card-numbered", "card-tag-image": "card-numbered", "comparison-2col": "dark-bullet-list", "compare-2col-split": "dark-bullet-list", "compare-pill-pair": "dark-bullet-list", "section-title-with-bg": None, "section-header-bar": None, "topic-center": "topic-left-right", "quote-big-mark": "quote-question", "image-full-caption": "image-row-2col", } def _get_block_height(block_type: str) -> int: """블록 타입의 height_cost를 px로 반환.""" catalog_map = _load_catalog_map_for_height() cost_label = catalog_map.get(block_type, "medium") return HEIGHT_COST_PX.get(cost_label, 150) def _load_catalog_map_for_height() -> dict[str, str]: """catalog.yaml에서 id → height_cost 매핑을 로드.""" catalog_path = Path(__file__).parent.parent / "templates" / "catalog.yaml" if not catalog_path.exists(): return {} try: with open(catalog_path, encoding="utf-8") as f: data = yaml.safe_load(f) return {b["id"]: b.get("height_cost", "medium") for b in data.get("blocks", [])} except Exception: return {} def _load_catalog_purpose_fit() -> dict[str, list[str]]: """catalog.yaml에서 id → purpose_fit 매핑을 로드.""" catalog_path = Path(__file__).parent.parent / "templates" / "catalog.yaml" if not catalog_path.exists(): return {} try: with open(catalog_path, encoding="utf-8") as f: data = yaml.safe_load(f) return { b["id"]: b.get("purpose_fit", []) for b in data.get("blocks", []) } except Exception: return {} def _validate_purpose_fit(blocks: list[dict]) -> int: """각 블록의 purpose_fit을 검증하고, 불일치 시 대체한다. Returns: 교체된 블록 수. """ purpose_fit_map = _load_catalog_purpose_fit() replaced = 0 for block in blocks: block_type = block.get("type", "") purpose = block.get("purpose", "") if not block_type or not purpose: continue allowed_purposes = purpose_fit_map.get(block_type, []) # purpose_fit이 빈 리스트면 범용 블록 → 검증 스킵 if not allowed_purposes: continue if purpose not in allowed_purposes: # Kei가 확정한 블록이므로 경고만 출력. 강제 교체 안 함. logger.warning( f"[purpose_fit 검증] '{block_type}'의 purpose_fit={allowed_purposes}에 " f"'{purpose}' 없음 — Kei 확정이므로 유지" ) return replaced def _validate_height_budget(blocks: list[dict], preset: dict) -> list[dict]: """zone별 height_cost 합산을 검증한다. (I-9 개정) 금지 블록 교체, pill-pair 단독 검증은 수행하되, 높이 초과 시 블록을 자동 교체하지 않는다. 대신 overflow 정보를 수집하여 반환 → pipeline에서 Kei에게 판단 요청. Returns: overflow 정보 리스트. 초과 없으면 빈 리스트. """ zones = preset.get("zones", {}) gap_px = 20 # --spacing-block # zone별 블록 그룹핑 zone_blocks: dict[str, list[dict]] = {} for block in blocks: area = block.get("area", "body") if area not in zone_blocks: zone_blocks[area] = [] zone_blocks[area].append(block) # 금지 블록 처리: 교체 또는 삭제 (J-2: None이면 삭제) blocks_to_remove = [] for block in blocks: area = block.get("area", "body") block_type = block.get("type", "") if area != "header" and block_type in BODY_FORBIDDEN_MAP: replacement = BODY_FORBIDDEN_MAP[block_type] if replacement is None: blocks_to_remove.append(block) logger.warning( f"[금지 블록 삭제] {block_type} (area={area})" ) else: block["type"] = replacement logger.warning( f"[금지 블록 교체] {block_type} → {replacement} (area={area})" ) for block in blocks_to_remove: blocks.remove(block) # [legacy Phase R'/Q example — INTEGRATION-AUDIT-01 §10.4] # 삭제 후 zone_blocks 재구성 (후속 pill-pair/높이 체크에 반영) zone_blocks.clear() for block in blocks: area = block.get("area", "body") if area not in zone_blocks: zone_blocks[area] = [] zone_blocks[area].append(block) # Phase M: sidebar 블록-zone 적합성 검증 (P-6) for block in blocks: if block.get("area") == "sidebar" and block.get("type") in SIDEBAR_FORBIDDEN_BLOCKS: replacement = SIDEBAR_FORBIDDEN_BLOCKS[block["type"]] if replacement is None: logger.warning(f"[zone 적합성] sidebar에서 {block['type']} 삭제") else: logger.warning(f"[zone 적합성] sidebar: {block['type']} → {replacement}") block["type"] = replacement # sidebar 카드 블록 1열 강제 (J-6) CARD_BLOCKS = { "card-tag-image", "card-icon-desc", "card-image-3col", "card-dark-overlay", "card-compare-3col", "card-image-round", "card-stat-number", } for block in blocks: if block.get("area") == "sidebar" and block.get("type") in CARD_BLOCKS: if "data" not in block: block["data"] = {} block["data"]["column_override"] = 1 # compare-pill-pair 단독 사용 금지 (I-7) COMPARISON_BLOCKS = {"compare-2col-split", "compare-3col-badge", "comparison-2col"} for area, area_blocks in zone_blocks.items(): types = {b.get("type") for b in area_blocks} if "compare-pill-pair" in types and not types & COMPARISON_BLOCKS: for block in area_blocks: if block.get("type") == "compare-pill-pair": block["type"] = "comparison-2col" logger.warning( "[pill-pair 단독 금지] compare-pill-pair → comparison-2col" ) # 높이 예산 검증 — 초과 시 자동 조치 + overflow 정보 수집 overflows: list[dict] = [] for area, area_blocks in zone_blocks.items(): zone_info = zones.get(area, {}) budget = zone_info.get("budget_px", 490) total = sum(_get_block_height(b.get("type", "")) for b in area_blocks) total += gap_px * max(0, len(area_blocks) - 1) if total <= budget: continue overflow_px = total - budget # footer 초과 자동 조치: banner-gradient의 sub_text 제거로 높이 축소 if area == "footer" and overflow_px <= 30: for block in area_blocks: if block.get("type") == "banner-gradient": if "data" not in block: block["data"] = {} block["data"]["_strip_sub_text"] = True logger.info( f"[높이 자동 조치] footer 초과 {overflow_px}px → " f"banner-gradient sub_text 제거" ) # sub_text 제거 시 compact(50px)로 줄어들므로 재계산 total_after = sum( 50 if (b.get("type") == "banner-gradient" and b.get("data", {}).get("_strip_sub_text")) else _get_block_height(b.get("type", "")) for b in area_blocks ) total_after += gap_px * max(0, len(area_blocks) - 1) if total_after <= budget: continue # 조치 후 예산 이내 → overflow 아님 logger.warning( f"[높이 예산 초과] {area}: {total}px > {budget}px. " f"블록: {[b.get('type') for b in area_blocks]}" ) overflows.append({ "area": area, "overflow_px": total - budget, "budget_px": budget, "total_px": total, "blocks": [ { "type": b.get("type", ""), "purpose": b.get("purpose", ""), "topic_id": b.get("topic_id"), "height_px": _get_block_height(b.get("type", "")), } for b in area_blocks ], }) return overflows