Files
C.E.L_Slide_test2/src/pipeline.py
T
kmlee1andClaude Opus 4.6 5d8706172c Phase H 보완 구현: 1단계 A/B 분리 + 정밀 검토 8개 반영
H-1a: KEI_PROMPT에 제목 중복 방지 지시 추가
H-1b: KEI_PROMPT_B (컨셉 구체화) + refine_concepts() 신규 함수
  - 각 꼭지의 relation_type, expression_hint, source_data 판단
  - 1회 호출로 전체 꼭지 처리
  - session_id: "design-agent-refine" (별도)
  - 실패 시 1단계-A 결과 그대로 반환 (pipeline 안 멈춤)
H-5: 팀장에게 relation_type + expression_hint + source_data 전달
  - 꼭지 요약에 관계/표현/원본데이터 포함
section-title-with-bg body 금지:
  - STEP_B_PROMPT에 규칙 추가
  - BODY_FORBIDDEN_MAP + _validate_height_budget에서 코드 레벨 교체
manual_classify fallback 동기화:
  - core_message, purpose, source_hint 기본값 추가

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 07:57:39 +09:00

367 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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, refine_concepts
from src.design_director import create_layout_concept, LAYOUT_PRESETS, select_preset
from src.content_editor import fill_content
from src.renderer import render_slide
from src.image_utils import get_image_sizes, embed_images
from src.config import settings
logger = logging.getLogger(__name__)
async def generate_slide(
content: str,
manual_layout: dict[str, Any] | None = None,
base_path: str = "",
) -> 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단계-A 완료: {topic_count}개 꼭지, {page_count}페이지")
# 1단계-B: 각 꼭지 컨셉 구체화
yield {"event": "progress", "data": "1.5/5 Kei 실장이 각 꼭지의 컨셉을 구체화 중..."}
analysis = await refine_concepts(content, analysis)
logger.info("1단계-B 완료: 컨셉 구체화")
# 이미지 크기 측정 (base_path 있을 때만)
image_sizes = get_image_sizes(content, base_path)
if image_sizes:
analysis["image_sizes"] = image_sizes
logger.info(f"이미지 측정: {len(image_sizes)}개")
# 2단계: 디자인 팀장 — Step A(프리셋) + Step B(블록 매핑)
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 디자인 실무자가 슬라이드를 조립 중..."}
layout_concept = await _adjust_design(layout_concept, analysis)
html = render_slide(layout_concept)
logger.info("4단계 완료: HTML 조립")
# 5단계: 디자인 팀장 — 전체 재검토 (최대 MAX_REVIEW_ROUNDS회)
MAX_REVIEW_ROUNDS = 2 # 무한 루프 방지 — 최대 재조정 횟수
yield {"event": "progress", "data": "5/5 디자인 팀장이 전체 균형을 검토 중..."}
for review_round in range(MAX_REVIEW_ROUNDS):
review_result = await _review_balance(html, layout_concept, content)
if not review_result or not review_result.get("needs_adjustment"):
if review_round == 0:
logger.info("5단계 완료: 조정 불필요")
else:
logger.info(f"5단계 완료: {review_round}차 조정 후 균형 확인")
break
issues = review_result.get("issues", [])
logger.info(
f"5단계 ({review_round + 1}/{MAX_REVIEW_ROUNDS}): "
f"조정 필요 — {issues}"
)
layout_concept = await _apply_adjustments(
layout_concept, review_result, content
)
html = render_slide(layout_concept)
logger.info(f"5단계: {review_round + 1}차 조정 반영, 재검토 진행")
else:
# MAX_REVIEW_ROUNDS 초과
logger.warning(
f"5단계: 최대 재조정 횟수({MAX_REVIEW_ROUNDS}) 도달. 현재 결과로 확정."
)
# D-5: 이미지를 base64로 삽입 (다운로드 HTML에서도 보이도록)
if base_path:
html = embed_images(html, base_path)
logger.info("이미지 base64 삽입 완료")
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 _adjust_design(
layout_concept: dict[str, Any],
analysis: dict[str, Any],
) -> dict[str, Any]:
"""4단계 전반: 디자인 실무자가 텍스트 양에 맞게 CSS를 조정한다.
각 area별 블록 수, 텍스트 총량, zone 예산을 계산하고,
Sonnet이 area별 CSS 변수 override를 결정한다.
블록 템플릿이 이미 CSS 변수(var(--font-body) 등)를 사용하므로,
area div에서 변수를 override하면 내부 블록이 자동 조정된다.
"""
try:
client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
# 프리셋 정보 가져오기
preset_name = select_preset(analysis)
preset = LAYOUT_PRESETS.get(preset_name, {})
zones = preset.get("zones", {})
for page in layout_concept.get("pages", []):
# area별 블록 수 + 텍스트 총량 집계
area_info = {}
for block in page.get("blocks", []):
area = block.get("area", "body")
if area not in area_info:
zone = zones.get(area, {})
area_info[area] = {
"block_count": 0,
"total_chars": 0,
"budget_px": zone.get("budget_px", 490),
"width_pct": zone.get("width_pct", 100),
"block_types": [],
}
data = block.get("data", {})
text_len = len(json.dumps(data, ensure_ascii=False))
area_info[area]["block_count"] += 1
area_info[area]["total_chars"] += text_len
area_info[area]["block_types"].append(block.get("type", ""))
# area 정보 텍스트 구성
area_lines = []
for area_name, info in area_info.items():
area_lines.append(
f"- {area_name} (예산 {info['budget_px']}px, 너비 {info['width_pct']}%): "
f"{info['block_count']}개 블록, 총 {info['total_chars']}자\n"
f" 블록 타입: {', '.join(info['block_types'])}"
)
system = (
"당신은 디자인 실무자이다. 편집자가 정리한 텍스트가 각 영역에 잘 들어가도록 CSS를 조정한다.\n\n"
"## 원칙\n"
"- 텍스트를 자르지 않는다. 디자인이 텍스트에 맞춘다.\n"
"- 빈 공간을 방치하지 않는다.\n"
"- 텍스트가 많으면: 폰트/여백을 줄여서 맞춘다.\n"
"- 텍스트가 적으면: 폰트/여백을 늘려서 채운다.\n\n"
"## 조정 가능한 CSS 변수\n"
"- --font-body (기본 0.95rem): 본문 폰트 크기\n"
"- --font-subtitle (기본 1.25rem): 소제목 폰트 크기\n"
"- --font-caption (기본 0.8rem): 캡션 폰트 크기\n"
"- --spacing-inner (기본 16px): 블록 내부 여백\n"
"- --spacing-block (기본 20px): 블록 간 간격\n"
"- --spacing-small (기본 8px): 작은 여백\n\n"
"## 출력 형식 (JSON만. 설명 없이.)\n"
"각 area에 적용할 CSS 변수 override를 inline style 문자열로 반환.\n"
"조정 불필요한 area는 빈 문자열.\n"
'{"area_styles": {"body": "--font-body: 0.85rem; --spacing-inner: 10px;", "sidebar": "", "footer": ""}}'
)
user_prompt = (
f"## 각 영역 현황\n" + "\n".join(area_lines) +
f"\n\n위 영역별로 CSS 변수 조정이 필요한지 판단하여 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
result = _parse_json(result_text)
if result and "area_styles" in result:
page["area_styles"] = result["area_styles"]
logger.info(
f"디자인 조정: {', '.join(f'{k}={bool(v)}' for k, v in result['area_styles'].items())}"
)
else:
page["area_styles"] = {}
logger.info("디자인 조정: 조정 불필요 또는 파싱 실패")
except Exception as e:
logger.warning(f"디자인 조정 실패 (기존 스타일로 렌더링): {e}")
# 실패 시 area_styles 없음 → 기존과 동일하게 렌더링
for page in layout_concept.get("pages", []):
if "area_styles" not in page:
page["area_styles"] = {}
return layout_concept
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차 조립 결과(HTML)를 검토하여 균형을 점검한다.\n\n"
"## 점검 항목\n"
"1. 빈 블록: 데이터가 없거나 극히 적은 블록\n"
"2. 채움 불균형: 한 블록은 빽빽하고 다른 블록은 비어있음\n"
"3. 이미지/표: 너무 작거나 큰 것은 없는지\n"
"4. 전체 정보량: 한 페이지에 너무 많거나 적은지\n"
"5. HTML 구조: 블록이 영역 안에 잘 배치되었는지\n\n"
"## 조정 action 설명\n"
"- expand: 텍스트를 늘린다. target_ratio로 얼마나 늘릴지 지정 (예: 1.3 = 30% 증가)\n"
"- shrink: 텍스트를 줄인다. target_ratio로 얼마나 줄일지 지정 (예: 0.7 = 30% 감소)\n"
"- rewrite: 텍스트를 완전히 재작성한다. detail에 재작성 방향 명시.\n\n"
"## 출력 형식 (JSON만)\n"
'{"needs_adjustment": true/false, '
'"issues": ["이슈1", "이슈2"], '
'"adjustments": [{"block_area": "...", "action": "expand|shrink|rewrite", '
'"target_ratio": 1.3, "detail": "..."}]}'
)
user_prompt = (
f"## 1차 조립 HTML\n{html}\n\n"
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"위 HTML과 데이터를 보고 조정이 필요한지 판단해. 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", "")
ratio = adj.get("target_ratio")
detail = adj.get("detail", "")
for page in layout_concept.get("pages", []):
for block in page.get("blocks", []):
if block.get("area") != area:
continue
if action == "expand" and ratio:
for key in block.get("char_guide", {}):
block["char_guide"][key] = int(
block["char_guide"][key] * ratio
)
logger.info(f"조정: {area} → expand ×{ratio} ({detail})")
elif action == "shrink" and ratio:
for key in block.get("char_guide", {}):
block["char_guide"][key] = int(
block["char_guide"][key] * ratio
)
logger.info(f"조정: {area} → shrink ×{ratio} ({detail})")
elif action == "rewrite":
if "data" in block:
del block["data"]
block["reason"] = f"재작성: {detail}"
logger.info(f"조정: {area} → rewrite ({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