diff --git a/src/design_director.py b/src/design_director.py index e6075be..a27e035 100644 --- a/src/design_director.py +++ b/src/design_director.py @@ -1,8 +1,7 @@ -"""DA-13: 2단계 — 디자인 팀장 (레이아웃 설계). +"""DA-13a + DA-13b: 2단계 — 디자인 팀장. -실장의 꼭지 분석 결과를 받아, -각 꼭지에 적합한 블록을 매핑하고 공간 배분 + 글자 수 가이드를 결정한다. -텍스트 정리는 하지 않는다. +Step A: 레이아웃 프리셋 선택 (규칙 기반, LLM 불필요) +Step B: 프리셋 안에서 블록 매핑 + 글자 수 가이드 (Sonnet) """ from __future__ import annotations @@ -18,7 +17,9 @@ from src.config import settings logger = logging.getLogger(__name__) -# 블록별 슬롯 정의 (content_editor, renderer에서도 참조) +# ────────────────────────────────────── +# 블록별 슬롯 정의 +# ────────────────────────────────────── BLOCK_SLOTS = { "comparison": { "required": ["left_title", "left_content", "right_title", "right_content"], @@ -58,100 +59,161 @@ BLOCK_SLOTS = { }, } +# ────────────────────────────────────── +# 레이아웃 프리셋 정의 +# ────────────────────────────────────── +LAYOUT_PRESETS = { + "sidebar-right": { + "description": "좌측 본문 흐름 + 우측 참조 사이드바", + "grid_areas": "'title title' 'body sidebar' 'footer footer'", + "grid_columns": "65fr 35fr", + "grid_rows": "auto 1fr auto", + "zones": { + "body": "flow 꼭지 배치 (위→아래 순서)", + "sidebar": "reference 꼭지 배치 (독립 참조)", + "footer": "결론 꼭지", + }, + }, + "two-column": { + "description": "대등한 2단 비교", + "grid_areas": "'title title' 'left right' 'footer footer'", + "grid_columns": "1fr 1fr", + "grid_rows": "auto 1fr auto", + "zones": { + "left": "첫 번째 비교 대상", + "right": "두 번째 비교 대상", + "footer": "결론 꼭지", + }, + }, + "hero-detail": { + "description": "고강조 1개 + 보조 상세", + "grid_areas": "'title title' 'hero hero' 'detail detail' 'footer footer'", + "grid_columns": "1fr 1fr", + "grid_rows": "auto 2fr 1fr auto", + "zones": { + "hero": "고강조 꼭지 (크게)", + "detail": "나머지 보조 꼭지", + "footer": "결론 꼭지", + }, + }, + "single-column": { + "description": "단일 컬럼 순차 배치", + "grid_areas": "'title' 'body' 'footer'", + "grid_columns": "1fr", + "grid_rows": "auto 1fr auto", + "zones": { + "body": "모든 꼭지 위→아래 순서", + "footer": "결론 꼭지", + }, + }, +} + +# ────────────────────────────────────── +# 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 _load_catalog() -> str: - """catalog.yaml이 있으면 로드하여 프롬프트용 텍스트 반환. 없으면 기본 블록 목록.""" + """catalog.yaml 로드.""" catalog_path = Path(__file__).parent.parent / "templates" / "catalog.yaml" if catalog_path.exists(): return catalog_path.read_text(encoding="utf-8") - # fallback: 기본 블록 목록 return """사용 가능한 블록: -- quote-block: 좌측 컬러 라인 + 인용 텍스트. 문제 제기, 핵심 주장할 때. +- quote-block: 좌측 컬러 라인 + 인용 텍스트. 문제 제기할 때. - card-grid: 2~4열 카드. 용어 정의, 개념 나열할 때. - comparison: 2단 병렬. A vs B 비교할 때. -- comparison-table: 다항목 비교 테이블. 행/열 많을 때. +- comparison-table: 다항목 비교 테이블. - relationship: 벤 다이어그램. 포함/상위-하위 관계할 때. -- process: 단계 흐름. 절차, 워크플로우할 때. -- conclusion-bar: 하단 결론 바. 핵심 한 줄. -- image-block: 이미지 + 캡션. full(전체너비)/side(텍스트옆)/thumb(썸네일) 3변형. -- details-block: 자세히보기. 요약 표면 + 펼치면 상세.""" +- process: 단계 흐름. 절차할 때. +- conclusion-bar: 하단 결론 바.""" -DIRECTOR_PROMPT = """당신은 디자인 팀장이다. 실장이 분석한 꼭지 목록을 받아 레이아웃을 설계한다. +STEP_B_PROMPT = """당신은 디자인 팀장이다. 레이아웃 프리셋이 이미 선택되었다. 당신은 프리셋 안에서 블록을 배정하기만 하면 된다. + +## 선택된 레이아웃 프리셋: {preset_name} +{preset_description} + +### CSS Grid (변경하지 마라): +grid-template-areas: {grid_areas} +grid-template-columns: {grid_columns} +grid-template-rows: {grid_rows} + +### Zone 설명: +{zone_descriptions} ## 역할 -- 실장의 info_structure(정보 구조)와 각 꼭지의 role(flow/reference)을 **반드시 존중**한다 -- 각 꼭지에 적합한 블록을 매핑한다 -- 전체 공간을 배분하고 겹침을 방지한다 -- 각 블록의 글자 수 가이드를 결정한다 -- **텍스트는 절대 정리하지 않는다** (텍스트 편집자가 별도로 한다) - -## 정보 구조 기반 배치 (가장 중요한 규칙) -실장이 각 꼭지에 role을 부여했다. 이 role에 따라 배치 영역이 결정된다: -- **role: "flow"** (본문 흐름) → 좌측 또는 메인 영역에 배치. 위→아래 순서대로. -- **role: "reference"** (참조 정보) → 우측 사이드 영역에 독립 배치. 본문 흐름과 분리. -- **detail_target: true** (상세 내용) → 본문에 넣지 않는다. popup/자세히보기로 분리. - -배치 예시: -- 본문 흐름(flow) 꼭지 3개 + 참조(reference) 꼭지 1개 → 좌측에 flow 3개, 우측에 reference 1개 -- 모든 꼭지가 flow → 단일 컬럼 또는 균등 분할 -- detail_target 꼭지 → 해당 블록에 연결된 별도 영역 (현재 블록 없으면 생략) - -## 중복 방지 규칙 -- 같은 내용이 두 개 블록에 나오면 안 된다 -- 예: 용어 정의가 카드에도 있고 비교 블록에도 있으면 → 하나만 선택 -- 블록 타입이 다르더라도 같은 내용이면 중복 +- 각 꼭지를 위 zone 중 하나에 배정한다 +- flow 꼭지 → body/main/left/hero zone +- reference 꼭지 → sidebar zone +- detail_target 꼭지 → 생략 (popup으로 분리, 현재 미구현) +- conclusion 꼭지 → footer zone +- 각 꼭지에 적합한 블록 타입을 catalog에서 선택한다 +- 같은 내용이 두 블록에 중복되면 안 된다 +- 각 블록의 대략적 글자 수 가이드를 제시한다 ## {catalog} -## 이미지 처리 규칙 -- 원본 이미지를 그대로 사용한다 (crop 안 함, 크기만 조절) -- 가로형 이미지(비율 > 1.2) → 전체 너비(image-full) -- 세로형 이미지(비율 < 0.8) → 텍스트 옆(image-side) -- 텍스트 포함 도표 → 너무 작게 축소하면 안 됨 - -## 표 처리 규칙 -- 표는 표로 유지한다 (다른 형태로 전환하지 않음) -- 공간에 안 들어가면 → 요약 요청 또는 페이지 분리 - -## 자세히보기 규칙 -- detail_target: true인 꼭지는 본문에 넣지 않는다 -- 관련된 블록 근처에 popup/링크로 연결 - -## 공간 배분 규칙 -- CSS grid-template-areas 형식으로 배치 -- 영역명: header, left, right, center, main, footer 등 -- flow 꼭지는 좌측/메인, reference 꼭지는 우측/사이드 -- 꼭지끼리 겹치지 않도록 설계 -- 각 블록에 대략적 크기 감(small/medium/large) 제시 - -## 글자 수 가이드 규칙 -- 블록의 공간에 따라 대략적 글자 수 가이드를 제시 -- 이것은 하드코딩 기준이 아니라 참고 가이드 -- 텍스트 편집자가 의미를 우선하여 가이드와 다를 수 있음 - ## 출력 형식 (반드시 JSON만. 설명 없이.) +grid_areas, grid_columns, grid_rows는 위에 정해진 것을 그대로 사용한다. ```json -{{ +{{{{ "pages": [ - {{ - "grid_areas": "'header header' 'left right' 'footer footer'", - "grid_columns": "1fr 1fr", - "grid_rows": "auto 1fr auto", + {{{{ + "grid_areas": "{grid_areas}", + "grid_columns": "{grid_columns}", + "grid_rows": "{grid_rows}", "blocks": [ - {{ - "area": "header", - "type": "quote-block", + {{{{ + "area": "zone이름", + "type": "블록타입", "topic_id": 1, - "reason": "문제 제기 꼭지", - "size": "small", - "char_guide": {{"quote_text": 80, "source": 30}} - }} + "reason": "이유", + "size": "small|medium|large", + "char_guide": {{{{"slot": 글자수}}}} + }}}} ] - }} + }}}} ] -}} +}}}} ```""" @@ -159,58 +221,63 @@ async def create_layout_concept( content: str, analysis: dict[str, Any], ) -> dict[str, Any]: - """2단계: 디자인 팀장이 레이아웃 컨셉을 설계한다. + """2단계: Step A(프리셋) + Step B(블록 매핑). Args: - content: 원본 텍스트 (분량 참고용) + content: 원본 텍스트 analysis: 1단계 실장의 꼭지 분석 결과 Returns: - 레이아웃 컨셉: - {"title": "...", "pages": [{"grid_areas": "...", "blocks": [...]}]} + 레이아웃 컨셉 JSON """ - client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key) + # Step A: 프리셋 선택 (규칙 기반) + preset_name = select_preset(analysis) + preset = LAYOUT_PRESETS[preset_name] + # Step B: 프리셋 내 블록 매핑 (Sonnet) + client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key) catalog_text = _load_catalog() - # 꼭지 요약 (role과 detail_target 포함) + # zone 설명 텍스트 + zone_desc = "\n".join( + f"- {name}: {desc}" for name, desc in preset["zones"].items() + ) + + # 꼭지 요약 topics_summary = [] for t in analysis.get("topics", []): role = t.get("role", "flow") line = ( - f"꼭지 {t['id']}: {t['title']} " + f"꼭지 {t.get('id', '?')}: {t.get('title', '?')} " f"[{t.get('layer', '?')}, ROLE:{role}, " - f"강조:{t.get('emphasis', False)}, " - f"방향:{t.get('direction', '?')}, 유형:{t.get('content_type', 'text')}]" + f"강조:{t.get('emphasis', False)}]" ) if t.get("detail_target"): - line += " → ★자세히보기 대상 (본문에 넣지 마라)" - if t.get("image_info"): - line += f" 이미지:{t['image_info']}" - if t.get("table_info"): - line += f" 표:{t['table_info']}" - if t.get("detail_target"): - line += " → 자세히보기 대상" + line += " → ★detail_target (생략)" topics_summary.append(line) - system = DIRECTOR_PROMPT.replace("{catalog}", catalog_text) + system = STEP_B_PROMPT.format( + preset_name=preset_name, + preset_description=preset["description"], + grid_areas=preset["grid_areas"], + grid_columns=preset["grid_columns"], + grid_rows=preset["grid_rows"], + zone_descriptions=zone_desc, + catalog=catalog_text, + ) - info_structure = analysis.get("info_structure", "정보 구조 미분석") + info_structure = analysis.get("info_structure", "") user_prompt = ( f"## 실장 분석 결과\n" f"제목: {analysis.get('title', '')}\n" - f"페이지 수: {analysis.get('total_pages', 1)}\n" f"정보 구조: {info_structure}\n\n" f"꼭지 목록:\n" + "\n".join(topics_summary) + f"\n\n## 원본 콘텐츠 (분량 참고)\n{content[:2000]}\n\n" f"## 요청\n" - f"위 꼭지를 어떤 블록으로, 어디에 배치할지 설계해줘.\n" - f"반드시 각 꼭지의 ROLE(flow/reference)에 따라 영역을 배정해라.\n" - f"flow → 좌측/메인, reference → 우측/사이드.\n" - f"detail_target → 본문에 넣지 마라.\n" - f"같은 내용이 두 블록에 중복되면 안 된다.\n" - f"텍스트는 채우지 마. 구조만 JSON으로." + f"위 꼭지를 프리셋의 zone에 배정하고 블록 타입을 선택해줘.\n" + f"grid_areas/columns/rows는 위에 정해진 것을 그대로 써라. 변경하지 마라.\n" + f"JSON만." ) try: @@ -225,35 +292,50 @@ async def create_layout_concept( concept = _parse_json(result_text) if concept and "pages" in concept: - total_blocks = sum(len(p.get("blocks", [])) for p in concept["pages"]) + total_blocks = sum( + len(p.get("blocks", [])) for p in concept["pages"] + ) logger.info( - f"레이아웃 설계 완료: {len(concept['pages'])}페이지, " - f"{total_blocks}개 블록" + f"[Step B] 블록 매핑 완료: {preset_name}, " + f"{len(concept['pages'])}페이지, {total_blocks}개 블록" ) return { "title": analysis.get("title", "슬라이드"), **concept, } else: - logger.warning("레이아웃 설계 파싱 실패. fallback 사용.") + logger.warning("블록 매핑 JSON 파싱 실패. fallback.") except Exception as e: - logger.error(f"디자인 팀장 호출 실패: {e}", exc_info=True) + logger.error(f"Step B 호출 실패: {e}", exc_info=True) - # fallback - return _fallback_layout(analysis) + # fallback: 프리셋 기반 기본 배치 + return _fallback_layout(analysis, preset_name, preset) -def _fallback_layout(analysis: dict[str, Any]) -> dict[str, Any]: - """팀장 실패 시 기본 레이아웃.""" +def _fallback_layout( + analysis: dict[str, Any], + preset_name: str, + preset: dict[str, Any], +) -> dict[str, Any]: + """Step B 실패 시 프리셋 기반 기본 배치.""" blocks = [] - areas = ["header", "main", "footer"] - for i, topic in enumerate(analysis.get("topics", [])[:3]): - area = areas[min(i, len(areas) - 1)] + for topic in analysis.get("topics", []): + if topic.get("detail_target"): + continue + + role = topic.get("role", "flow") + if role == "reference" and preset_name == "sidebar-right": + area = "sidebar" + elif topic.get("layer") == "conclusion": + area = "footer" + else: + area = "body" if preset_name != "two-column" else "left" + blocks.append({ "area": area, "type": "card-grid", - "topic_id": topic.get("id", i + 1), + "topic_id": topic.get("id", 0), "reason": topic.get("title", ""), "size": "medium", "char_guide": {"title": 20, "description": 100}, @@ -262,9 +344,9 @@ def _fallback_layout(analysis: dict[str, Any]) -> dict[str, Any]: return { "title": analysis.get("title", "슬라이드"), "pages": [{ - "grid_areas": "'header' 'main' 'footer'", - "grid_columns": "1fr", - "grid_rows": "auto 1fr auto", + "grid_areas": preset["grid_areas"], + "grid_columns": preset["grid_columns"], + "grid_rows": preset["grid_rows"], "blocks": blocks, }], } diff --git a/src/pipeline.py b/src/pipeline.py index 2b8cad3..5ed09e3 100644 --- a/src/pipeline.py +++ b/src/pipeline.py @@ -48,7 +48,7 @@ async def generate_slide( page_count = analysis.get("total_pages", 1) logger.info(f"1단계 완료: {topic_count}개 꼭지, {page_count}페이지") - # 2단계: 디자인 팀장 — 레이아웃 설계 + # 2단계: 디자인 팀장 — Step A(프리셋) + Step B(블록 매핑) yield {"event": "progress", "data": "2/5 디자인 팀장이 레이아웃을 설계 중..."} layout_concept = await create_layout_concept(content, analysis)