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
co-authored by Claude Opus 4.6
commit c42e65fc7e
28 changed files with 3302 additions and 0 deletions
+161
View File
@@ -0,0 +1,161 @@
"""DA-13b: 텍스트 편집자 — 슬롯 텍스트 정리 (Kei 역할).
디자인 팀장의 레이아웃 컨셉 + 원본 콘텐츠를 받아,
각 슬롯에 맞는 텍스트를 도메인 전문가로서 정리한다.
핵심 내용을 유지하면서 슬롯 분량에 맞게 편집.
"""
from __future__ import annotations
import json
import logging
import re
from typing import Any
import anthropic
from src.config import settings
from src.design_director import BLOCK_SLOTS
logger = logging.getLogger(__name__)
async def fill_content(
content: str,
layout_concept: dict[str, Any],
) -> dict[str, Any]:
"""각 페이지의 각 블록 슬롯에 텍스트를 채운다.
Args:
content: 원본 텍스트 콘텐츠
layout_concept: 디자인 팀장의 레이아웃 컨셉
{"title": "...", "pages": [{"blocks": [...]}]}
Returns:
슬롯이 채워진 layout_concept (pages[n].blocks[m].data에 텍스트 추가)
"""
client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
for page_idx, page in enumerate(layout_concept.get("pages", [])):
blocks = page.get("blocks", [])
if not blocks:
continue
# 슬롯 요구사항 생성
slot_requirements = []
for i, block in enumerate(blocks):
block_type = block["type"]
slots = BLOCK_SLOTS.get(block_type, {})
slot_requirements.append(
f"블록 {i+1} ({block_type}, 영역: {block['area']}):\n"
f" 필수 슬롯: {slots.get('required', [])}\n"
f" 선택 슬롯: {slots.get('optional', [])}\n"
f" 용도: {block.get('reason', '미지정')}"
)
system_prompt = (
"당신은 도메인 전문가이자 콘텐츠 편집자이다.\n"
"원본 콘텐츠의 핵심 내용을 유지하면서 각 블록의 슬롯에 맞게 텍스트를 정리한다.\n\n"
"## 규칙\n"
"- 핵심 내용과 맥락을 보존한다. 과도한 요약 금지.\n"
"- 개조식(불릿, 번호)으로 작성한다. 줄글 금지.\n"
"- 출처가 있는 내용은 출처를 보존한다.\n"
"- 출처가 없는 수치나 통계를 만들지 않는다.\n"
"- 각 슬롯의 분량을 지킨다:\n"
" - 제목(title): 최대 30자\n"
" - 본문(content/description): 최대 200자\n"
" - 설명(subtitle/source): 최대 80자\n"
" - 카드 설명: 카드당 최대 150자\n"
"- JSON 형식으로만 응답한다. 설명 없이 JSON만.\n\n"
"## 슬롯 구조 참고\n"
"- comparison: {left_title, left_content, right_title, right_content}\n"
"- card-grid: {cards: [{title, description, category?, source?}]}\n"
"- relationship: {center_label, center_sub?, items: [{label, color?}], description?}\n"
"- process: {steps: [{title, description?, number?}]}\n"
"- quote-block: {quote_text, source?}\n"
"- conclusion-bar: {conclusion_text, label?}\n"
"- comparison-table: {headers: [...], rows: [[...], ...]}\n"
)
page_label = f"(페이지 {page_idx + 1}/{len(layout_concept['pages'])})" if len(layout_concept['pages']) > 1 else ""
user_prompt = (
f"## 원본 콘텐츠\n{content}\n\n"
f"## 블록 배치 {page_label}\n"
+ "\n".join(slot_requirements)
+ "\n\n## 요청\n"
"위 블록별로 슬롯에 들어갈 텍스트를 정리하여 JSON으로 반환해줘.\n"
"원본의 핵심 내용을 충실하게 반영하되, 각 슬롯 분량에 맞게 편집해.\n"
"형식:\n"
'{"blocks": [{"area": "...", "type": "...", "data": {슬롯 키-값}}]}'
)
try:
response = await client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=4096,
system=system_prompt,
messages=[{"role": "user", "content": user_prompt}],
)
result_text = response.content[0].text
filled = _parse_json(result_text)
if filled and "blocks" in filled:
for filled_block in filled["blocks"]:
for orig_block in blocks:
if orig_block["area"] == filled_block.get("area"):
orig_block["data"] = filled_block.get("data", {})
break
logger.info(
f"텍스트 정리 완료 (페이지 {page_idx + 1}): "
f"{len(filled['blocks'])}개 블록"
)
else:
logger.warning(f"텍스트 정리 파싱 실패 (페이지 {page_idx + 1}). 기본값 사용.")
_apply_defaults(blocks)
except Exception as e:
logger.error(f"텍스트 편집자 호출 실패: {e}", exc_info=True)
_apply_defaults(blocks)
return layout_concept
def _apply_defaults(blocks: list[dict[str, Any]]) -> None:
"""실패 시 기본 데이터 적용."""
defaults = {
"quote-block": {"quote_text": "(텍스트 정리 실패)"},
"card-grid": {"cards": []},
"conclusion-bar": {"conclusion_text": "(결론 생성 실패)"},
"comparison": {
"left_title": "항목 A", "left_content": "-",
"right_title": "항목 B", "right_content": "-",
},
"relationship": {
"center_label": "관계도", "center_sub": "",
"items": [], "description": "",
},
"process": {"steps": []},
"comparison-table": {"headers": [], "rows": []},
}
for block in blocks:
if "data" not in block:
block["data"] = defaults.get(block["type"], {})
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