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

141
src/kei_client.py Normal file
View File

@@ -0,0 +1,141 @@
"""DA-12: Kei API 연동 + Opus 직접 분류.
1차: Opus API를 직접 호출하여 콘텐츠 유형을 분류한다 (안정적).
2차: Kei API 연동은 향후 RAG 통합 시 활용.
Opus 실패 시: 수동 분류 fallback.
"""
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__)
CLASSIFICATION_PROMPT = """당신은 콘텐츠를 분석하여 슬라이드 레이아웃을 결정하는 실장이다.
## 사용 가능한 블록 타입
- comparison: 2단 병렬 비교 (A vs B)
- card-grid: 카드 배열 (용어 정의, 개념 설명)
- relationship: 벤 다이어그램 (상위/하위, 포함 관계)
- process: 단계 흐름 (절차, 워크플로우)
- quote-block: 강조 인용 (문제 제기, 핵심 메시지)
- conclusion-bar: 결론 바 (핵심 한 줄)
- comparison-table: 다항목 비교 테이블
## 배치 영역
grid-template-areas로 정의. 사용 가능한 영역명: header, left, right, center, main, footer
## 규칙
- 콘텐츠를 분석하여 각 덩어리의 유형을 판단한다
- 한 슬라이드에 블록 4~6개가 적절하다
- 정보 계층: 위→아래 (문제 제기 → 분석 → 결론)
- 반드시 JSON으로만 응답한다. 설명 없이 JSON만.
## 출력 형식
```json
{
"title": "슬라이드 제목",
"grid_areas": "'header header' 'left right' 'footer footer'",
"grid_columns": "1fr 1fr",
"grid_rows": "auto 1fr auto",
"blocks": [
{"area": "header", "type": "quote-block", "reason": "문제 제기"},
{"area": "left", "type": "comparison", "reason": "정책 비교"},
{"area": "right", "type": "card-grid", "reason": "용어 정의 3개"},
{"area": "footer", "type": "conclusion-bar", "reason": "핵심 결론"}
]
}
```"""
async def classify_content(content: str) -> dict[str, Any] | None:
"""Opus API를 직접 호출하여 콘텐츠를 분류한다.
Args:
content: 원본 텍스트 콘텐츠
Returns:
분류 결과 JSON. 실패 시 None.
"""
if not settings.anthropic_api_key:
logger.warning("ANTHROPIC_API_KEY 미설정. 수동 분류 모드.")
return None
try:
client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
response = await client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=2048,
system=CLASSIFICATION_PROMPT,
messages=[
{"role": "user", "content": f"다음 콘텐츠의 레이아웃을 결정해줘:\n\n{content}"}
],
)
result_text = response.content[0].text
layout = _parse_layout_json(result_text)
if layout and "blocks" in layout:
logger.info(
f"콘텐츠 분류 완료: {layout.get('title', 'untitled')}, "
f"{len(layout['blocks'])}개 블록"
)
return layout
else:
logger.warning(f"분류 JSON 파싱 실패. 응답: {result_text[:200]}")
return None
except Exception as e:
logger.warning(f"Opus 분류 호출 실패: {e}")
return None
def _parse_layout_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
def manual_classify(content: str) -> dict[str, Any]:
"""Opus 실패 시 기본 레이아웃을 반환하는 fallback."""
return {
"title": "슬라이드",
"grid_areas": "'header' 'main' 'footer'",
"grid_columns": "1fr",
"grid_rows": "auto 1fr auto",
"blocks": [
{
"area": "header",
"type": "quote-block",
"reason": "기본 인용 블록",
},
{
"area": "main",
"type": "card-grid",
"reason": "기본 카드 그리드",
},
{
"area": "footer",
"type": "conclusion-bar",
"reason": "기본 결론",
},
],
}