"""DA-14: 전체 파이프라인 (5단계). 1. Kei 실장: 꼭지 추출 + 분석 2. 디자인 팀장: 레이아웃 설계 3. 텍스트 편집자: 텍스트 정리 4. 디자인 실무자: HTML 조립 5. 디자인 팀장: 전체 재검토 """ from __future__ import annotations import json import logging import re from typing import Any, AsyncIterator import anthropic from src.kei_client import classify_content, manual_classify from src.design_director import create_layout_concept from src.content_editor import fill_content from src.renderer import render_slide from src.config import settings logger = logging.getLogger(__name__) async def generate_slide( content: str, manual_layout: dict[str, Any] | None = None, ) -> AsyncIterator[dict[str, str]]: """콘텐츠를 슬라이드 HTML로 변환하는 5단계 파이프라인. Yields: SSE 이벤트: progress / result / error """ try: # 1단계: Kei 실장 — 꼭지 추출 + 분석 yield {"event": "progress", "data": "1/5 Kei 실장이 꼭지를 추출 중..."} if manual_layout: analysis = manual_layout else: analysis = await classify_content(content) if analysis is None: analysis = manual_classify(content) topic_count = len(analysis.get("topics", [])) page_count = analysis.get("total_pages", 1) logger.info(f"1단계 완료: {topic_count}개 꼭지, {page_count}페이지") # 2단계: 디자인 팀장 — 레이아웃 설계 yield {"event": "progress", "data": "2/5 디자인 팀장이 레이아웃을 설계 중..."} layout_concept = await create_layout_concept(content, analysis) total_blocks = sum( len(p.get("blocks", [])) for p in layout_concept.get("pages", []) ) logger.info( f"2단계 완료: {len(layout_concept.get('pages', []))}페이지, " f"{total_blocks}개 블록" ) # 3단계: 텍스트 편집자 — 텍스트 정리 yield {"event": "progress", "data": "3/5 텍스트 편집자가 핵심을 정리 중..."} layout_concept = await fill_content(content, layout_concept, analysis) logger.info("3단계 완료: 텍스트 정리") # 4단계: 디자인 실무자 — HTML 조립 yield {"event": "progress", "data": "4/5 디자인 실무자가 슬라이드를 조립 중..."} html = render_slide(layout_concept) logger.info("4단계 완료: HTML 조립") # 5단계: 디자인 팀장 — 전체 재검토 yield {"event": "progress", "data": "5/5 디자인 팀장이 전체 균형을 검토 중..."} review_result = await _review_balance(html, layout_concept, content) if review_result and review_result.get("needs_adjustment"): logger.info( f"5단계: 조정 필요 — {review_result.get('issues', [])}" ) # 조정 지시에 따라 텍스트 재편집 또는 레이아웃 재조정 layout_concept = await _apply_adjustments( layout_concept, review_result, content ) html = render_slide(layout_concept) logger.info("5단계 완료: 2차 조정 반영") else: logger.info("5단계 완료: 조정 불필요") yield {"event": "result", "data": html} logger.info(f"슬라이드 생성 완료: {len(layout_concept.get('pages', []))}페이지") except Exception as e: logger.exception(f"파이프라인 오류: {e}") yield {"event": "error", "data": str(e)} async def _review_balance( html: str, layout_concept: dict[str, Any], content: str, ) -> dict[str, Any] | None: """5단계: 디자인 팀장이 1차 조립 결과를 재검토한다. HTML 코드 기반으로 구조적 점검: - 빈 블록 감지 - 블록 간 채움 비율 불균형 - 이미지/표 크기 적절성 """ try: client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key) # 블록별 텍스트 양 요약 block_summary = [] for page in layout_concept.get("pages", []): for block in page.get("blocks", []): data = block.get("data", {}) text_len = len(json.dumps(data, ensure_ascii=False)) block_summary.append( f" {block.get('area')}/{block.get('type')}: " f"데이터 {text_len}자" ) system = ( "당신은 디자인 팀장이다. 1차 조립 결과를 검토하여 균형을 점검한다.\n\n" "## 점검 항목\n" "1. 빈 블록: 데이터가 없거나 극히 적은 블록\n" "2. 채움 불균형: 한 블록은 빽빽하고 다른 블록은 비어있음\n" "3. 이미지/표: 너무 작거나 큰 것은 없는지\n" "4. 전체 정보량: 한 페이지에 너무 많거나 적은지\n\n" "## 출력 형식 (JSON만)\n" '{"needs_adjustment": true/false, ' '"issues": ["이슈1", "이슈2"], ' '"adjustments": [{"block_area": "...", "action": "expand|shrink|rewrite", "detail": "..."}]}' ) user_prompt = ( f"## 블록별 데이터 양\n" + "\n".join(block_summary) + f"\n\n## 레이아웃 구조\n" f"페이지 수: {len(layout_concept.get('pages', []))}\n" f"총 블록 수: {sum(len(p.get('blocks', [])) for p in layout_concept.get('pages', []))}\n\n" f"조정이 필요한가? JSON으로 답해." ) response = await client.messages.create( model="claude-sonnet-4-20250514", max_tokens=1024, system=system, messages=[{"role": "user", "content": user_prompt}], ) result_text = response.content[0].text return _parse_json(result_text) except Exception as e: logger.warning(f"재검토 실패: {e}") return None async def _apply_adjustments( layout_concept: dict[str, Any], review: dict[str, Any], content: str, ) -> dict[str, Any]: """재검토 결과에 따라 텍스트를 재편집한다.""" adjustments = review.get("adjustments", []) if not adjustments: return layout_concept # 조정이 필요한 블록만 재편집 for adj in adjustments: area = adj.get("block_area", "") action = adj.get("action", "") detail = adj.get("detail", "") for page in layout_concept.get("pages", []): for block in page.get("blocks", []): if block.get("area") == area and action in ("expand", "rewrite"): # 해당 블록의 char_guide를 조정하여 재편집 유도 if action == "expand": for key in block.get("char_guide", {}): block["char_guide"][key] = int( block["char_guide"][key] * 1.5 ) logger.info(f"조정: {area} → {action} ({detail})") # 조정된 가이드로 재편집 layout_concept = await fill_content(content, layout_concept) return layout_concept def _parse_json(text: str) -> dict[str, Any] | None: """텍스트에서 JSON을 추출한다.""" patterns = [ r"```json\s*(.*?)```", r"```\s*(.*?)```", r"(\{.*\})", ] for pattern in patterns: match = re.search(pattern, text, re.DOTALL) if match: try: return json.loads(match.group(1).strip()) except json.JSONDecodeError: continue return None