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:
141
src/kei_client.py
Normal file
141
src/kei_client.py
Normal 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": "기본 결론",
|
||||
},
|
||||
],
|
||||
}
|
||||
Reference in New Issue
Block a user