Initial commit: Kei Design Agent

콘텐츠를 시각적으로 구조화된 슬라이드 HTML로 변환하는 독립 에이전트.

아키텍처 (4단계 파이프라인):
  1. Kei 실장 (Opus) — 콘텐츠 유형 분류 + 블록 배치
  2. 디자인 팀장 (Sonnet) — 레이아웃 컨셉 (블록 배치 + 페이지 수)
  3. 텍스트 편집자 (Sonnet) — 슬롯 텍스트 정리 (핵심 유지)
  4. CSS Grid 렌더러 — HTML 조립

블록 템플릿 7종:
  comparison, card-grid, relationship, process,
  quote-block, conclusion-bar, comparison-table

기술 스택:
  FastAPI + Anthropic API + Jinja2 + CSS Grid
  Pretendard Variable 한국어 폰트

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-24 17:25:47 +09:00
commit c42e65fc7e
28 changed files with 3302 additions and 0 deletions

172
src/design_director.py Normal file
View File

@@ -0,0 +1,172 @@
"""DA-13: 디자인 팀장 — 레이아웃 컨셉만 (Sonnet).
Opus의 분류 결과 + 원본 콘텐츠를 받아,
레이아웃 컨셉(블록 배치 + 페이지 수 + 슬롯 목록)만 결정한다.
텍스트 정리는 하지 않는다 — content_editor가 담당.
"""
from __future__ import annotations
import json
import logging
import re
from typing import Any
import anthropic
from src.config import settings
logger = logging.getLogger(__name__)
# 블록별 슬롯 정의 (content_editor에서도 참조)
BLOCK_SLOTS = {
"comparison": {
"required": ["left_title", "left_content", "right_title", "right_content"],
"optional": ["left_subtitle", "right_subtitle"],
},
"card-grid": {
"required": ["cards"],
"optional": [],
},
"relationship": {
"required": ["center_label", "items"],
"optional": ["center_sub", "description"],
},
"process": {
"required": ["steps"],
"optional": [],
},
"quote-block": {
"required": ["quote_text"],
"optional": ["source"],
},
"conclusion-bar": {
"required": ["conclusion_text"],
"optional": ["label"],
},
"comparison-table": {
"required": ["headers", "rows"],
"optional": [],
},
}
async def create_layout_concept(
content: str,
classification: dict[str, Any],
) -> dict[str, Any]:
"""디자인 팀장이 레이아웃 컨셉을 결정한다.
텍스트는 채우지 않는다. 블록 배치, 페이지 수, 슬롯 목록만 반환.
Args:
content: 원본 텍스트 콘텐츠
classification: Opus의 분류 결과
Returns:
레이아웃 컨셉:
{
"pages": [
{
"grid_areas": "...",
"grid_columns": "...",
"grid_rows": "...",
"blocks": [{"area": "header", "type": "quote-block", "reason": "..."}]
}
],
"title": "슬라이드 제목"
}
"""
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', '')}"
)
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": "..."}]}]}'
)
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"위 블록을 몇 페이지에 어떻게 배치할지 결정해줘. "
f"텍스트는 채우지 마. 배치 구조만 JSON으로 반환해."
)
try:
response = await client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=2048,
system=system_prompt,
messages=[{"role": "user", "content": user_prompt}],
)
result_text = response.content[0].text
concept = _parse_json(result_text)
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"{total_blocks}개 블록"
)
return concept
else:
logger.warning("레이아웃 컨셉 파싱 실패. 기존 분류를 1페이지로 사용.")
except Exception as e:
logger.error(f"디자인 팀장 호출 실패: {e}", exc_info=True)
# fallback: 기존 분류를 1페이지로 감싸기
return _fallback_single_page(classification)
def _fallback_single_page(classification: dict[str, Any]) -> dict[str, Any]:
"""분류 결과를 1페이지 컨셉으로 변환 (fallback)."""
return {
"title": classification.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", []),
}],
}
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