5단계 파이프라인 전면 재작성 + Figma 추출 계획 업데이트

DA-12: 1단계 Kei 실장 — 꼭지 2~5개 추출 + 레이어/강조/배치/이미지/표/자세히보기 판단
DA-13: 2단계 디자인 팀장 — catalog 연동 + 블록 매핑 + 공간 배분 + 글자 수 가이드
DA-13b: 3단계 텍스트 편집자 — 글자 수 가이드 참고, 의미 우선 편집 + 자세히보기(요약+상세)
DA-14: 4단계 실무자(AI+코드) + 5단계 팀장 재검토 (균형 점검 → 2차 조정)

문서:
- CLAUDE.md: 5단계 프로세스 + 이미지/표/자세히보기 처리 원칙
- PLAN.md: DA-12~14 태스크 전면 재작성
- PROGRESS.md: 동기화
- FIGMA-COMPONENT-EXTRACTION-PLAN.md: 모드 독립 블록, 변환 규칙, image-block/details-block, MCP, 토큰 매핑

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-25 08:44:10 +09:00
parent 9a780828df
commit 33bd3a56c6
9 changed files with 1015 additions and 397 deletions

View File

@@ -1,14 +1,15 @@
"""DA-13: 디자인 팀장 레이아웃 컨셉만 (Sonnet).
"""DA-13: 2단계 — 디자인 팀장 (레이아웃 설계).
Opus의 분류 결과 + 원본 콘텐츠를 받아,
레이아웃 컨셉(블록 + 페이지 수 + 슬롯 목록)만 결정한다.
텍스트 정리는 하지 않는다 — content_editor가 담당.
실장의 꼭지 분석 결과를 받아,
각 꼭지에 적합한 블록을 매핑하고 공간 + 글자 수 가이드를 결정한다.
텍스트 정리는 하지 않는다.
"""
from __future__ import annotations
import json
import logging
import re
from pathlib import Path
from typing import Any
import anthropic
@@ -17,7 +18,7 @@ from src.config import settings
logger = logging.getLogger(__name__)
# 블록별 슬롯 정의 (content_editor에서도 참조)
# 블록별 슬롯 정의 (content_editor, renderer에서도 참조)
BLOCK_SLOTS = {
"comparison": {
"required": ["left_title", "left_content", "right_title", "right_content"],
@@ -47,78 +48,147 @@ BLOCK_SLOTS = {
"required": ["headers", "rows"],
"optional": [],
},
"image-block": {
"required": ["src", "alt"],
"optional": ["caption", "layout"],
},
"details-block": {
"required": ["summary_text", "detail_content"],
"optional": ["label"],
},
}
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")
# fallback: 기본 블록 목록
return """사용 가능한 블록:
- quote-block: 좌측 컬러 라인 + 인용 텍스트. 문제 제기, 핵심 주장할 때.
- card-grid: 2~4열 카드. 용어 정의, 개념 나열할 때.
- comparison: 2단 병렬. A vs B 비교할 때.
- comparison-table: 다항목 비교 테이블. 행/열 많을 때.
- relationship: 벤 다이어그램. 포함/상위-하위 관계할 때.
- process: 단계 흐름. 절차, 워크플로우할 때.
- conclusion-bar: 하단 결론 바. 핵심 한 줄.
- image-block: 이미지 + 캡션. full(전체너비)/side(텍스트옆)/thumb(썸네일) 3변형.
- details-block: 자세히보기. 요약 표면 + 펼치면 상세."""
DIRECTOR_PROMPT = """당신은 디자인 팀장이다. 실장이 분석한 꼭지 목록을 받아 레이아웃을 설계한다.
## 역할
- 각 꼭지에 적합한 블록을 매핑한다
- 전체 공간을 배분하고 겹침을 방지한다
- 각 블록의 글자 수 가이드를 결정한다
- **텍스트는 절대 정리하지 않는다** (텍스트 편집자가 별도로 한다)
## {catalog}
## 이미지 처리 규칙
- 원본 이미지를 그대로 사용한다 (crop 안 함, 크기만 조절)
- 가로형 이미지(비율 > 1.2) → 전체 너비(image-full)
- 세로형 이미지(비율 < 0.8) → 텍스트 옆(image-side)
- 텍스트 포함 도표 → 너무 작게 축소하면 안 됨
## 표 처리 규칙
- 표는 표로 유지한다 (다른 형태로 전환하지 않음)
- 공간에 안 들어가면 → 요약 요청 또는 페이지 분리
## 자세히보기 규칙
- 너무 구체적/세부적인 내용은 details-block으로 설계
- 슬라이드 표면: 요약만, 펼치면: 전체 상세
## 공간 배분 규칙
- CSS grid-template-areas 형식으로 배치
- 영역명: header, left, right, center, main, footer 등
- 꼭지끼리 겹치지 않도록 설계
- 각 블록에 대략적 크기 감(small/medium/large) 제시
## 글자 수 가이드 규칙
- 블록의 공간에 따라 대략적 글자 수 가이드를 제시
- 이것은 하드코딩 기준이 아니라 참고 가이드
- 텍스트 편집자가 의미를 우선하여 가이드와 다를 수 있음
## 출력 형식 (반드시 JSON만. 설명 없이.)
```json
{{
"pages": [
{{
"grid_areas": "'header header' 'left right' 'footer footer'",
"grid_columns": "1fr 1fr",
"grid_rows": "auto 1fr auto",
"blocks": [
{{
"area": "header",
"type": "quote-block",
"topic_id": 1,
"reason": "문제 제기 꼭지",
"size": "small",
"char_guide": {{"quote_text": 80, "source": 30}}
}}
]
}}
]
}}
```"""
async def create_layout_concept(
content: str,
classification: dict[str, Any],
analysis: dict[str, Any],
) -> dict[str, Any]:
"""디자인 팀장이 레이아웃 컨셉을 결정한다.
텍스트는 채우지 않는다. 블록 배치, 페이지 수, 슬롯 목록만 반환.
"""2단계: 디자인 팀장이 레이아웃 컨셉을 설계한다.
Args:
content: 원본 텍스트 콘텐츠
classification: Opus의 분류 결과
content: 원본 텍스트 (분량 참고용)
analysis: 1단계 실장의 꼭지 분석 결과
Returns:
레이아웃 컨셉:
{
"pages": [
{
"grid_areas": "...",
"grid_columns": "...",
"grid_rows": "...",
"blocks": [{"area": "header", "type": "quote-block", "reason": "..."}]
}
],
"title": "슬라이드 제목"
}
{"title": "...", "pages": [{"grid_areas": "...", "blocks": [...]}]}
"""
client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
# 기존 분류에서 블록 목록 추출
blocks = classification.get("blocks", [])
block_summary = []
for i, block in enumerate(blocks):
block_summary.append(
f"{i+1}. {block['type']} (영역: {block['area']}) — {block.get('reason', '')}"
)
catalog_text = _load_catalog()
system_prompt = (
"당신은 디자인 팀장이다. 콘텐츠의 구조를 보고 레이아웃 컨셉을 결정한다.\n\n"
"## 역할\n"
"- 블록 배치와 페이지 수만 결정한다\n"
"- 텍스트 내용은 절대 정리하지 않는다 (텍스트 편집자가 별도로 한다)\n\n"
"## 규칙\n"
"- 1페이지에 4~5파트가 적절하다\n"
"- 6파트 이상이면 2페이지로 나눈다\n"
"- 핵심 파트를 억지로 줄이지 않는다\n"
"- CSS grid-template-areas 형식으로 배치를 지정한다\n"
"- JSON 형식으로만 응답한다\n\n"
"## 사용 가능한 블록 타입\n"
"comparison, card-grid, relationship, process, quote-block, conclusion-bar, comparison-table\n\n"
"## 출력 형식\n"
'{"title": "제목", "pages": [{"grid_areas": "...", "grid_columns": "...", "grid_rows": "...", '
'"blocks": [{"area": "...", "type": "...", "reason": "..."}]}]}'
)
# 꼭지 요약
topics_summary = []
for t in analysis.get("topics", []):
line = (
f"꼭지 {t['id']}: {t['title']} "
f"[{t.get('layer', '?')}, 강조:{t.get('emphasis', False)}, "
f"방향:{t.get('direction', '?')}, 유형:{t.get('content_type', 'text')}]"
)
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 += " → 자세히보기 대상"
topics_summary.append(line)
system = DIRECTOR_PROMPT.replace("{catalog}", catalog_text)
user_prompt = (
f"## Opus 실장 결과\n"
f"제목: {classification.get('title', '')}\n"
f"블록 목록:\n" + "\n".join(block_summary) +
f"\n\n## 원본 콘텐츠 (분량 참고용)\n{content[:2000]}\n\n"
f"## 실장 분 결과\n"
f"제목: {analysis.get('title', '')}\n"
f"페이지 수: {analysis.get('total_pages', 1)}\n"
f"꼭지 목록:\n" + "\n".join(topics_summary) +
f"\n\n## 원본 콘텐츠 (분량 참고)\n{content[:2000]}\n\n"
f"## 요청\n"
f"블록을 몇 페이지에 어떻게 배치할지 결정해줘. "
f"텍스트는 채우지 마. 배치 구조만 JSON으로 반환해."
f"꼭지를 어떤 블록으로, 어디에, 몇 페이지 배치할지 설계해줘.\n"
f"텍스트는 채우지 마. 구조만 JSON으로."
)
try:
response = await client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=2048,
system=system_prompt,
system=system,
messages=[{"role": "user", "content": user_prompt}],
)
@@ -128,29 +198,45 @@ async def create_layout_concept(
if concept and "pages" in concept:
total_blocks = sum(len(p.get("blocks", [])) for p in concept["pages"])
logger.info(
f"레이아웃 컨셉 완료: {len(concept['pages'])}페이지, "
f"레이아웃 설계 완료: {len(concept['pages'])}페이지, "
f"{total_blocks}개 블록"
)
return concept
return {
"title": analysis.get("title", "슬라이드"),
**concept,
}
else:
logger.warning("레이아웃 컨셉 파싱 실패. 기존 분류를 1페이지로 사용.")
logger.warning("레이아웃 설계 파싱 실패. fallback 사용.")
except Exception as e:
logger.error(f"디자인 팀장 호출 실패: {e}", exc_info=True)
# fallback: 기존 분류를 1페이지로 감싸기
return _fallback_single_page(classification)
# fallback
return _fallback_layout(analysis)
def _fallback_single_page(classification: dict[str, Any]) -> dict[str, Any]:
"""분류 결과를 1페이지 컨셉으로 변환 (fallback)."""
def _fallback_layout(analysis: dict[str, Any]) -> dict[str, Any]:
"""팀장 실패 시 기본 레이아웃."""
blocks = []
areas = ["header", "main", "footer"]
for i, topic in enumerate(analysis.get("topics", [])[:3]):
area = areas[min(i, len(areas) - 1)]
blocks.append({
"area": area,
"type": "card-grid",
"topic_id": topic.get("id", i + 1),
"reason": topic.get("title", ""),
"size": "medium",
"char_guide": {"title": 20, "description": 100},
})
return {
"title": classification.get("title", "슬라이드"),
"title": analysis.get("title", "슬라이드"),
"pages": [{
"grid_areas": classification.get("grid_areas", "'header' 'main' 'footer'"),
"grid_columns": classification.get("grid_columns", "1fr"),
"grid_rows": classification.get("grid_rows", "auto 1fr auto"),
"blocks": classification.get("blocks", []),
"grid_areas": "'header' 'main' 'footer'",
"grid_columns": "1fr",
"grid_rows": "auto 1fr auto",
"blocks": blocks,
}],
}
@@ -158,9 +244,9 @@ def _fallback_single_page(classification: dict[str, Any]) -> dict[str, Any]:
def _parse_json(text: str) -> dict[str, Any] | None:
"""텍스트에서 JSON을 추출한다."""
patterns = [
r'```json\s*(.*?)```',
r'```\s*(.*?)```',
r'(\{.*\})',
r"```json\s*(.*?)```",
r"```\s*(.*?)```",
r"(\{.*\})",
]
for pattern in patterns:
match = re.search(pattern, text, re.DOTALL)