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:
3
.env.example
Normal file
3
.env.example
Normal file
@@ -0,0 +1,3 @@
|
||||
ANTHROPIC_API_KEY=sk-ant-...
|
||||
KEI_API_URL=http://localhost:8000
|
||||
LOG_LEVEL=DEBUG
|
||||
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
.env
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
.venv/
|
||||
node_modules/
|
||||
data/
|
||||
321
CLAUDE.md
Normal file
321
CLAUDE.md
Normal file
@@ -0,0 +1,321 @@
|
||||
# Design Agent — 콘텐츠 시각 구조화 슬라이드 생성기
|
||||
|
||||
## 프로젝트 목적
|
||||
|
||||
텍스트 콘텐츠를 **1페이지 가로 슬라이드**로 시각 구조화하는 독립 에이전트.
|
||||
콘텐츠의 의미를 분석하여 적합한 레이아웃 블록을 선택하고, 핵심만 추출하여 깔끔한 HTML/CSS로 렌더링한다.
|
||||
|
||||
**핵심 원칙:** 전체 페이지를 하나의 고정 템플릿으로 찍어내는 것이 아니라, 콘텐츠를 분석 → 각 덩어리별로 적합한 레이아웃 블록 선택 → 조합하여 배치.
|
||||
|
||||
---
|
||||
|
||||
## 아키텍처
|
||||
|
||||
```
|
||||
Kei (실장) — Kei Persona API 호출
|
||||
"이 콘텐츠는 비교+정의+관계도 구조다. 이렇게 배치해라."
|
||||
↓
|
||||
디자인 팀장 (Sonnet)
|
||||
"비교는 2단, 정의는 카드 3열, 관계도는 벤. 핵심만 남기고 나머지 버려."
|
||||
↓
|
||||
실행자 (CSS Grid 렌더러)
|
||||
"팀장이 정한 대로 CSS Grid로 조립."
|
||||
```
|
||||
|
||||
### 역할 분리
|
||||
|
||||
| 역할 | 담당 | 하는 일 | 하지 않는 일 |
|
||||
|------|------|---------|------------|
|
||||
| Kei (실장) | Opus via Kei API | 콘텐츠 의미 분석, 유형 분류, 배치 방향 결정 | 디자인, CSS 작성 |
|
||||
| 디자인 팀장 | Sonnet | 블록 타입 선택, 콘텐츠 선별(70% 버림), 슬롯 채우기, 세부 기준 수립 | 콘텐츠 의미 판단 |
|
||||
| 실행자 | CSS Grid 렌더러 | 확정적 HTML/CSS 생성, 디자인 토큰 적용 | 판단, 선택 |
|
||||
|
||||
---
|
||||
|
||||
## 핵심 프로세스
|
||||
|
||||
```
|
||||
사용자 콘텐츠 입력 (텍스트 붙여넣기 또는 파일 업로드)
|
||||
↓
|
||||
[1단계] Kei 실장(Opus) — 콘텐츠 유형 분류
|
||||
→ "이건 비교(A vs B) + 정의(3개 용어) + 관계도(상위/하위)"
|
||||
→ 적합한 블록 조합 결정
|
||||
↓
|
||||
[2단계] 디자인 팀장(Sonnet) — 레이아웃 컨셉만
|
||||
→ "이 파트는 카드로, 이건 비교로, 2페이지 필요"
|
||||
→ 블록 배치 + 페이지 수 + 슬롯 목록 (텍스트는 채우지 않음)
|
||||
↓
|
||||
[3단계] 텍스트 편집자(Sonnet, Kei 역할) — 슬롯 텍스트 정리
|
||||
→ 도메인 전문가로서 원본 핵심을 유지하며 각 슬롯 분량에 맞게 편집
|
||||
→ 과도한 요약 금지, 출처 보존, 개조식 작성
|
||||
↓
|
||||
[4단계] 실행자(CSS Grid) — 확정적 HTML 생성
|
||||
→ 블록 타입에 맞는 CSS 템플릿 적용
|
||||
→ 디자인 토큰 (색상, 여백, 폰트 크기) 적용
|
||||
→ 다중 페이지 시 page-break 처리
|
||||
↓
|
||||
미리보기 → 사용자 확인 → HTML 다운로드
|
||||
```
|
||||
|
||||
**핵심 원칙:** 디자인 팀장은 레이아웃만 결정하고 콘텐츠를 건드리지 않는다. 텍스트 정리는 도메인 지식이 있는 Kei 역할(텍스트 편집자)이 한다.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 콘텐츠 유형 분류 기준
|
||||
|
||||
Opus가 콘텐츠를 분석하여 아래 유형으로 분류한다.
|
||||
**이 분류는 하드코딩이 아니라, Opus가 매번 사고하여 판단한다.**
|
||||
|
||||
| 텍스트 패턴 | 유형 | 적합한 블록 |
|
||||
|------------|------|-----------|
|
||||
| "A vs B", 장단점, 차이점 | 비교 | 2단 병렬 / 비교 테이블 |
|
||||
| "1단계 → 2단계 → 3단계" | 프로세스 | 플로우차트 / 단계 카드 |
|
||||
| "X는 Y를 포함한다", 상위-하위 | 구성/관계 | 벤 다이어그램 / 트리 |
|
||||
| 수치, KPI, 통계 | 핵심 지표 | 큰 숫자 + 보조 텍스트 |
|
||||
| 용어 정의, 개념 설명 | 정의 | 카드 3열 / 아이콘 카드 |
|
||||
| 기능/특성 나열 | 목록 | 아이콘 리스트 / 카드 그리드 |
|
||||
| 연도별 사건, 로드맵 | 시간 순서 | 타임라인 (가로/세로) |
|
||||
| 핵심 메시지, 결론 | 강조 | 결론 바 / 인용 블록 |
|
||||
| 문제 상황, 경고 | 문제 제기 | 경고 박스 / 강조 인용 |
|
||||
|
||||
---
|
||||
|
||||
## 블록 타입 정의
|
||||
|
||||
각 블록은 독립적인 CSS 컴포넌트로, 슬롯(교체 가능한 위치)을 가진다.
|
||||
|
||||
### 1. 비교 블록 (comparison)
|
||||
- 2단 병렬 레이아웃
|
||||
- 슬롯: 좌측 제목/내용, 우측 제목/내용
|
||||
- 용도: A vs B, 장단점, Before/After
|
||||
|
||||
### 2. 카드 그리드 (card-grid)
|
||||
- 2~4열 카드 배열
|
||||
- 슬롯: 카드별 아이콘/제목/설명/출처
|
||||
- 용도: 용어 정의, 개념 설명, 기능 나열
|
||||
|
||||
### 3. 관계도 (relationship)
|
||||
- 벤 다이어그램 또는 트리 구조
|
||||
- 슬롯: 중심 요소, 하위 요소들, 관계 설명
|
||||
- 용도: 상위-하위 관계, 포함 관계, 기술 융합
|
||||
|
||||
### 4. 프로세스 (process)
|
||||
- 가로 또는 세로 단계 흐름
|
||||
- 슬롯: 단계별 번호/제목/설명
|
||||
- 용도: 절차, 워크플로우, 파이프라인
|
||||
|
||||
### 5. 타임라인 (timeline)
|
||||
- 시간 축 기반 배치
|
||||
- 슬롯: 날짜/제목/설명
|
||||
- 용도: 연혁, 로드맵, 일정
|
||||
|
||||
### 6. 핵심 지표 (big-number)
|
||||
- 큰 숫자 + 보조 텍스트
|
||||
- 슬롯: 숫자, 단위, 설명
|
||||
- 용도: KPI, 통계, 성과 수치
|
||||
|
||||
### 7. 강조 인용 (quote-block)
|
||||
- 배경색 + 좌측 라인 + 인용 텍스트
|
||||
- 슬롯: 인용 텍스트, 출처
|
||||
- 용도: 문제 제기, 핵심 메시지, 정의
|
||||
|
||||
### 8. 결론 바 (conclusion-bar)
|
||||
- 하단 전체 폭 강조 영역
|
||||
- 슬롯: 핵심 한 줄
|
||||
- 용도: 슬라이드 결론, 요약 메시지
|
||||
|
||||
### 9. 비교 테이블 (comparison-table)
|
||||
- 테이블 형식의 다항목 비교
|
||||
- 슬롯: 행/열 헤더, 셀 내용
|
||||
- 용도: 다차원 비교, 기능 매트릭스
|
||||
|
||||
### 10. 이미지 참조 (image-ref)
|
||||
- 이미지 썸네일 + 캡션
|
||||
- 슬롯: 이미지 경로, 캡션 텍스트
|
||||
- 용도: 근거 자료, 문서 참조, 사진
|
||||
|
||||
---
|
||||
|
||||
## 페이지 구성 원칙
|
||||
|
||||
### 레이아웃 배치 규칙
|
||||
- CSS Grid 기반 (`grid-template-areas`)
|
||||
- 가로 슬라이드 비율: 16:9 (1280×720 또는 1920×1080)
|
||||
- 최대 블록 수: 1페이지에 4~6개
|
||||
- 정보 계층: 위 → 아래 (문제 제기 → 분석 → 결론)
|
||||
- 여백: 블록 간 최소 20px, 페이지 패딩 40px
|
||||
|
||||
### 블록 조합 예시
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ [강조 인용] 문제 제기 │
|
||||
├──────────────────┬──────────────────────────┤
|
||||
│ [비교] │ [카드 그리드] │
|
||||
│ 2단 비교 │ 정의 3열 │
|
||||
├──────────────────┴──────────────────────────┤
|
||||
│ [관계도] 벤 다이어그램 │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ [결론 바] 핵심 한 줄 │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 디자인 원칙 (절대 규칙)
|
||||
|
||||
### DO (해야 하는 것)
|
||||
- 여백을 충분히 확보한다 (여백 > 장식)
|
||||
- 색상은 최대 3개 (메인 1개 + 포인트 1개 + 중성 1개)
|
||||
- 폰트 크기 체계를 일관되게 유지 (제목/소제목/본문/캡션 4단계)
|
||||
- 흑백 기조 + 포인트 컬러 최소 사용
|
||||
- 정보 계층을 시각적으로 명확히 표현
|
||||
- 한 슬라이드에 메시지는 1개
|
||||
|
||||
### DON'T (하지 않는 것)
|
||||
- 그라데이션 배경 금지
|
||||
- CSS 애니메이션/트랜지션 금지
|
||||
- 호버 효과 금지
|
||||
- 그림자(box-shadow) 최소화 (1개 레벨만)
|
||||
- 원본 콘텐츠를 전부 넣으려 하지 않는다 (70% 버려라)
|
||||
- 다크 테마 금지 (요청하지 않는 한)
|
||||
- 둥근 모서리 과다 사용 금지 (border-radius 최대 8px)
|
||||
|
||||
---
|
||||
|
||||
## 디자인 토큰
|
||||
|
||||
```css
|
||||
:root {
|
||||
/* 색상 */
|
||||
--color-primary: #1e293b; /* 메인 (짙은 남색) */
|
||||
--color-accent: #2563eb; /* 포인트 (파랑) */
|
||||
--color-neutral: #64748b; /* 중성 (회색) */
|
||||
--color-bg: #ffffff; /* 배경 */
|
||||
--color-bg-subtle: #f8fafc; /* 보조 배경 */
|
||||
--color-border: #e2e8f0; /* 테두리 */
|
||||
--color-danger: #dc2626; /* 경고/문제 */
|
||||
|
||||
/* 폰트 크기 */
|
||||
--font-title: 2rem; /* 슬라이드 제목 */
|
||||
--font-subtitle: 1.25rem; /* 섹션 제목 */
|
||||
--font-body: 0.95rem; /* 본문 */
|
||||
--font-caption: 0.8rem; /* 캡션/출처 */
|
||||
|
||||
/* 여백 */
|
||||
--spacing-page: 40px; /* 페이지 패딩 */
|
||||
--spacing-block: 20px; /* 블록 간 간격 */
|
||||
--spacing-inner: 16px; /* 블록 내부 패딩 */
|
||||
|
||||
/* 기타 */
|
||||
--radius: 6px; /* 둥근 모서리 */
|
||||
--border-width: 1px; /* 테두리 두께 */
|
||||
--accent-border: 3px; /* 강조 테두리 */
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 교본 (레퍼런스) 관리
|
||||
|
||||
### 저장 위치
|
||||
```
|
||||
D:\ad-hoc\kei\design_agent\
|
||||
├── CLAUDE.md ← 이 파일
|
||||
├── templates/ ← 블록별 HTML/CSS 교본
|
||||
│ ├── comparison.html ← 비교 블록 교본
|
||||
│ ├── card-grid.html ← 카드 그리드 교본
|
||||
│ ├── relationship.html ← 관계도 교본
|
||||
│ ├── process.html ← 프로세스 교본
|
||||
│ ├── timeline.html ← 타임라인 교본
|
||||
│ ├── big-number.html ← 핵심 지표 교본
|
||||
│ ├── quote-block.html ← 강조 인용 교본
|
||||
│ ├── conclusion-bar.html ← 결론 바 교본
|
||||
│ ├── comparison-table.html ← 비교 테이블 교본
|
||||
│ └── image-ref.html ← 이미지 참조 교본
|
||||
├── samples/ ← 완성 슬라이드 샘플 (레퍼런스 이미지 + HTML)
|
||||
├── design-tokens.css ← 공통 디자인 토큰
|
||||
└── docs/ ← 조사 자료, 기술 문서
|
||||
```
|
||||
|
||||
### 교본 추가 방법
|
||||
1. 좋은 디자인 샘플을 찾는다 (CodePen, 직접 제작 등)
|
||||
2. HTML/CSS 코드를 `templates/` 폴더에 저장한다
|
||||
3. 슬롯 위치를 `{{SLOT_NAME}}` 형식으로 표시한다
|
||||
4. CLAUDE.md의 블록 타입 정의에 참조를 추가한다
|
||||
|
||||
### 교본 품질 기준
|
||||
- 디자인 원칙(DO/DON'T)을 준수하는가
|
||||
- 슬롯이 명확하게 분리되어 있는가
|
||||
- 디자인 토큰을 사용하는가 (하드코딩 색상 아닌 CSS 변수)
|
||||
- 1페이지 안에 들어가는 크기인가
|
||||
|
||||
---
|
||||
|
||||
## Kei API 연동
|
||||
|
||||
### 연동 방식
|
||||
- Design Agent는 Kei Persona 서버(`localhost:8000`)의 API를 호출하여 콘텐츠 분석을 요청한다
|
||||
- Kei 서버가 떠있어야 Design Agent가 동작한다
|
||||
- 향후 글벗에 붙일 때도 같은 API 호출 방식
|
||||
|
||||
### 호출 포인트
|
||||
| 단계 | API | 용도 |
|
||||
|------|-----|------|
|
||||
| 1단계 콘텐츠 분류 | Kei API (Opus) | 콘텐츠 유형 판단 + 배치 방향 |
|
||||
| 2단계 콘텐츠 선별 | Kei API (Sonnet) | 핵심 추출 + 슬롯 채우기 |
|
||||
| 3단계 렌더링 | 로컬 (CSS Grid) | HTML 생성 (API 불필요) |
|
||||
|
||||
### 독립 실행 가능
|
||||
- Kei API 없이도 2-3단계만으로 동작 가능 (사용자가 직접 유형 선택)
|
||||
- Kei API 연결 시 1단계 자동화
|
||||
|
||||
---
|
||||
|
||||
## 기술 스택 (예정)
|
||||
|
||||
| 역할 | 도구 | 비고 |
|
||||
|------|------|------|
|
||||
| 프론트엔드 | React + Vite | Kei와 동일 스택 |
|
||||
| 렌더링 | CSS Grid + 디자인 토큰 | 순수 CSS, 프레임워크 없음 |
|
||||
| AI 콘텐츠 분석 | Kei API (Opus + Sonnet) | localhost:8000 |
|
||||
| 출력 | HTML 다운로드 | PDF 불필요 |
|
||||
|
||||
---
|
||||
|
||||
## 향후 연결 가능성
|
||||
|
||||
```
|
||||
현재: 독립 개발 + 테스트
|
||||
↓
|
||||
검증 후 선택지:
|
||||
A) Kei 본체에 합치기 (대화 안에서 "슬라이드로 정리해줘")
|
||||
B) 글벗에 붙이기 (문서 자동화 → 시각화 단계)
|
||||
C) 둘 다
|
||||
```
|
||||
|
||||
독립적으로 만들어두면 어디에 붙이든 API 호출만 하면 된다.
|
||||
|
||||
---
|
||||
|
||||
## 업계 근거
|
||||
|
||||
- **SlideSpeak**: 16개 레이아웃 타입 + 슬롯 기반 매핑 (가장 실용적 아키텍처)
|
||||
- **Beautiful.ai**: 300개 템플릿 + 규칙 기반 자동 레이아웃 조정
|
||||
- **Napkin AI**: NLP로 텍스트 패턴 → 시각화 유형 자동 매핑
|
||||
- **PPTAgent (EMNLP 2025)**: 레퍼런스 슬라이드 클러스터링 → 유형별 패턴 추출 → 편집 방식 생성
|
||||
- **InfoDesignLM (ICDAR 2025)**: 텍스트만으로 인포그래픽 레이아웃 생성, GPT-4o 능가
|
||||
- **Microsoft LIDA**: 4단계 파이프라인 (요약 → 목표 → 시각화 → 스타일링)
|
||||
- **Dr. Andrew Abela Chart Chooser**: 콘텐츠 유형 → 시각화 유형 결정 트리
|
||||
|
||||
---
|
||||
|
||||
## 금지 사항
|
||||
|
||||
1. Kei Persona Agent 코드를 수정하지 않는다
|
||||
2. 디자인 판단을 하드코딩하지 않는다 (Opus/Sonnet이 사고한다)
|
||||
3. 전체 페이지를 하나의 고정 템플릿으로 만들지 않는다 (블록 조합 방식)
|
||||
4. 콘텐츠를 전부 넣으려 하지 않는다 (핵심만 추출)
|
||||
5. 그라데이션, 애니메이션, 다크 테마를 기본으로 사용하지 않는다
|
||||
6. 교본 없이 자유 디자인을 하지 않는다 (교본 참조 필수)
|
||||
171
PLAN.md
Normal file
171
PLAN.md
Normal file
@@ -0,0 +1,171 @@
|
||||
# Design Agent — 실행 계획
|
||||
|
||||
## Phase 1: 기반 구축
|
||||
|
||||
### DA-1: 프로젝트 셋업
|
||||
- **파일:** pyproject.toml, .env, .gitignore
|
||||
- **내용:** Python 환경, 의존성 정의, 환경 변수
|
||||
- **의존성:** 없음
|
||||
- **완료 기준:** `pip install -e .` 성공
|
||||
|
||||
### DA-2: FastAPI 서버 기본 구조
|
||||
- **파일:** src/main.py, src/config.py
|
||||
- **내용:** FastAPI 앱, CORS, health endpoint, 설정 관리
|
||||
- **의존성:** DA-1
|
||||
- **완료 기준:** `uvicorn src.main:app --reload` 정상 시작, `/api/health` 200 반환
|
||||
|
||||
### DA-3: 디자인 토큰 + 기본 CSS
|
||||
- **파일:** static/tokens.css, static/base.css
|
||||
- **내용:** CLAUDE.md에 정의된 디자인 토큰을 CSS 변수로 구현, Pretendard 폰트 설정, 16:9 슬라이드 컨테이너
|
||||
- **의존성:** 없음
|
||||
- **완료 기준:** 빈 슬라이드가 16:9 비율로 렌더링, Pretendard 폰트 적용 확인
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: 블록 템플릿 제작
|
||||
|
||||
### DA-4: 블록 템플릿 — 비교 (comparison)
|
||||
- **파일:** templates/blocks/comparison.html
|
||||
- **내용:** 2단 병렬 레이아웃, Jinja2 슬롯 ({{left_title}}, {{left_content}}, {{right_title}}, {{right_content}})
|
||||
- **의존성:** DA-3
|
||||
- **완료 기준:** 더미 데이터로 렌더링 시 2단 비교 표시, 디자인 토큰 적용
|
||||
|
||||
### DA-5: 블록 템플릿 — 카드 그리드 (card-grid)
|
||||
- **파일:** templates/blocks/card-grid.html
|
||||
- **내용:** 2~4열 카드 배열, Jinja2 슬롯 ({{cards[n].icon}}, {{cards[n].title}}, {{cards[n].description}})
|
||||
- **의존성:** DA-3
|
||||
- **완료 기준:** 3개 카드 렌더링, 카드 수에 따라 자동 배열
|
||||
|
||||
### DA-6: 블록 템플릿 — 관계도 (relationship)
|
||||
- **파일:** templates/blocks/relationship.html
|
||||
- **내용:** 벤 다이어그램 (CSS 원형), Jinja2 슬롯 ({{center}}, {{items[n]}})
|
||||
- **의존성:** DA-3
|
||||
- **완료 기준:** 3원 벤 다이어그램 렌더링, 라벨 표시
|
||||
|
||||
### DA-7: 블록 템플릿 — 프로세스 (process)
|
||||
- **파일:** templates/blocks/process.html
|
||||
- **내용:** 가로 단계 흐름, Jinja2 슬롯 ({{steps[n].number}}, {{steps[n].title}}, {{steps[n].description}})
|
||||
- **의존성:** DA-3
|
||||
- **완료 기준:** 4단계 프로세스 렌더링, 연결선 표시
|
||||
|
||||
### DA-8: 블록 템플릿 — 강조 인용 (quote-block)
|
||||
- **파일:** templates/blocks/quote-block.html
|
||||
- **내용:** 배경색 + 좌측 라인 + 인용 텍스트, Jinja2 슬롯 ({{quote_text}}, {{source}})
|
||||
- **의존성:** DA-3
|
||||
- **완료 기준:** 인용 블록 렌더링, 강조 스타일 적용
|
||||
|
||||
### DA-9: 블록 템플릿 — 결론 바 (conclusion-bar)
|
||||
- **파일:** templates/blocks/conclusion-bar.html
|
||||
- **내용:** 하단 전체 폭 강조 영역, Jinja2 슬롯 ({{conclusion_text}})
|
||||
- **의존성:** DA-3
|
||||
- **완료 기준:** 결론 바 렌더링, 강조 색상 적용
|
||||
|
||||
### DA-10: 블록 템플릿 — 비교 테이블 (comparison-table)
|
||||
- **파일:** templates/blocks/comparison-table.html
|
||||
- **내용:** 다항목 비교 테이블, Jinja2 슬롯 ({{headers}}, {{rows}})
|
||||
- **의존성:** DA-3
|
||||
- **완료 기준:** 5행 3열 테이블 렌더링
|
||||
|
||||
### DA-11: 슬라이드 조합 렌더러
|
||||
- **파일:** src/renderer.py, templates/slide-base.html
|
||||
- **내용:** Jinja2로 블록 조합 → HTML 생성. grid-template-areas로 블록 배치. 다중 페이지 지원.
|
||||
- **다중 페이지:** `.slide` div 여러 개 + `page-break-after: always` (인쇄 시 페이지 분리)
|
||||
- **의존성:** DA-4 ~ DA-10
|
||||
- **완료 기준:** JSON 블록 배치 명세 → 완성 HTML 출력 (1페이지 또는 다중 페이지)
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: AI 파이프라인 연결
|
||||
|
||||
### DA-12: Kei API 연동 — 콘텐츠 분류 (Opus)
|
||||
- **파일:** src/kei_client.py
|
||||
- **내용:** Kei API (`localhost:8000/api/message`)에 콘텐츠 전송 → Opus 분류 결과 수신. Kei API 미연결 시 수동 분류 fallback
|
||||
- **의존성:** DA-2
|
||||
- **완료 기준:** 테스트 콘텐츠 전송 → 유형 분류 JSON 반환
|
||||
|
||||
### DA-13: 디자인 팀장 — 레이아웃 컨셉 (Sonnet)
|
||||
- **파일:** src/design_director.py
|
||||
- **내용:** Anthropic API 직접 호출. Opus 분류 결과 + 원본 콘텐츠 → 레이아웃 컨셉만 결정. 텍스트 정리 안 함.
|
||||
- **출력:** 블록 배치 + 페이지 수 + 슬롯 목록 (텍스트 없이 구조만)
|
||||
- **기술:** Anthropic API (Sonnet), JSON 반환
|
||||
- **의존성:** DA-12
|
||||
- **완료 기준:** "이 파트는 카드로, 이건 비교로, 2페이지 필요" 수준의 컨셉 JSON 반환
|
||||
|
||||
### DA-13b: 텍스트 편집자 — 슬롯 텍스트 정리 (Kei 역할)
|
||||
- **파일:** src/content_editor.py (신규)
|
||||
- **내용:** Anthropic API 직접 호출. 디자인 팀장의 레이아웃 컨셉 + 원본 콘텐츠 → 각 슬롯에 맞는 텍스트 편집. 도메인 지식 보존, 핵심 유지.
|
||||
- **역할:** 도메인 전문가로서 콘텐츠를 정리하는 편집자 (Kei persona 규칙 일부 적용)
|
||||
- **규칙:** 핵심 내용 유지, 개조식, 출처 보존, 슬롯 분량 준수, 내용 날조 금지
|
||||
- **기술:** Anthropic API (Sonnet), JSON 반환
|
||||
- **의존성:** DA-13
|
||||
- **완료 기준:** 슬롯별 텍스트가 채워진 JSON 반환. 원본 핵심 내용 보존 확인.
|
||||
|
||||
### DA-14: 전체 파이프라인 연결 (3단계)
|
||||
- **파일:** src/pipeline.py
|
||||
- **내용:** 콘텐츠 입력 → Opus 분류 → 디자인 팀장 컨셉 → 텍스트 편집자 정리 → 렌더러 조립 → HTML 출력
|
||||
- **기술:** 순차 호출, 다중 페이지 지원
|
||||
- **의존성:** DA-11, DA-12, DA-13, DA-13b
|
||||
- **완료 기준:** 텍스트 입력 → 완성 슬라이드 HTML 출력 (엔드투엔드, 다중 페이지 포함)
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: UI + 출력
|
||||
|
||||
### DA-15: 프론트엔드 — 콘텐츠 입력 + 미리보기
|
||||
- **파일:** static/index.html (별도 HTML 파일), main.py (FileResponse로 서빙)
|
||||
- **내용:** 텍스트 입력 영역 + iframe 미리보기 + HTML 다운로드 버튼
|
||||
- **기술:** FileResponse (FastAPI 내장), fetch API + 수동 SSE 파싱
|
||||
- **의존성:** DA-14
|
||||
- **완료 기준:** 텍스트 붙여넣기 → 슬라이드 미리보기 표시 + HTML 다운로드
|
||||
- **주의:** HTML/JS를 Python 문자열에 넣지 않는다 (이스케이프 충돌 방지)
|
||||
|
||||
---
|
||||
|
||||
## 버그 수정
|
||||
|
||||
### BF-2: 블록 내용 비어있음 (렌더러 Jinja2 include 문제)
|
||||
- **파일:** src/renderer.py, templates/slide-base.html
|
||||
- **내용:** `include` → 블록별 개별 `render()` 후 HTML 삽입
|
||||
- **기술:** Jinja2 `get_template().render()` (내장)
|
||||
- **의존성:** 없음 (기존 코드 수정만)
|
||||
- **완료 기준:** 콘텐츠 입력 → 슬라이드에 텍스트가 표시됨
|
||||
|
||||
### BF-3: 한글 깨짐 (다운로드 파일)
|
||||
- **파일:** static/index.html
|
||||
- **내용:** download() Blob에 UTF-8 BOM 추가
|
||||
- **기술:** JavaScript `'\uFEFF'` 1줄
|
||||
- **의존성:** 없음
|
||||
- **완료 기준:** 다운로드한 HTML 파일에서 한글 정상 표시
|
||||
|
||||
### DA-16: 통합 테스트
|
||||
- **파일:** tests/test_pipeline.py, tests/test_renderer.py
|
||||
- **내용:** 전체 파이프라인 테스트 + 블록 렌더링 테스트
|
||||
- **의존성:** BF-2, BF-3
|
||||
- **완료 기준:** 테스트 전체 통과
|
||||
|
||||
---
|
||||
|
||||
## 의존 관계
|
||||
|
||||
```
|
||||
DA-1 → DA-2 → DA-12 → DA-13 ─┐
|
||||
├→ DA-14 → DA-15 → DA-16
|
||||
DA-3 → DA-4~DA-10 → DA-11 ────┘
|
||||
```
|
||||
|
||||
Phase 1(DA-1~3)과 Phase 2(DA-4~11)는 AI 없이 진행 가능.
|
||||
Phase 3(DA-12~14)부터 Kei API + Anthropic API 필요.
|
||||
|
||||
---
|
||||
|
||||
## 기술 스택
|
||||
|
||||
| 역할 | 도구 | 비고 |
|
||||
|------|------|------|
|
||||
| 서버 | FastAPI + uvicorn | Kei와 동일 |
|
||||
| 템플릿 엔진 | Jinja2 | 블록 상속 + 슬롯 변수 |
|
||||
| 렌더링 | CSS Grid + 디자인 토큰 | 순수 CSS |
|
||||
| 한국어 폰트 | Pretendard Variable | word-break: keep-all |
|
||||
| AI (실장) | Kei API (Opus) | localhost:8000 |
|
||||
| AI (팀장) | Anthropic API (Sonnet) | Structured Outputs |
|
||||
| 테스트 | pytest | 렌더링 + 파이프라인 |
|
||||
96
PROGRESS.md
Normal file
96
PROGRESS.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# Design Agent — 진행 상황
|
||||
|
||||
## 현재 상태 요약
|
||||
|
||||
| 상태 | 개수 |
|
||||
|------|------|
|
||||
| done | 13 |
|
||||
| in-progress | 0 |
|
||||
| todo | 3 |
|
||||
| blocked | 0 |
|
||||
| **전체** | **16** |
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: 기반 구축
|
||||
|
||||
| 태스크 | 상태 | 담당 | 시작 | 완료 | 메모 |
|
||||
|--------|------|------|------|------|------|
|
||||
| DA-1: 프로젝트 셋업 | done | - | - | - | pyproject.toml, .env |
|
||||
| DA-2: FastAPI 서버 | done | - | - | - | DA-1 이후 |
|
||||
| DA-3: 디자인 토큰 + 기본 CSS | done | - | - | - | 독립 작업 가능 |
|
||||
|
||||
## Phase 2: 블록 템플릿 제작
|
||||
|
||||
| 태스크 | 상태 | 담당 | 시작 | 완료 | 메모 |
|
||||
|--------|------|------|------|------|------|
|
||||
| DA-4: 비교 블록 | done | - | - | - | DA-3 이후 |
|
||||
| DA-5: 카드 그리드 | done | - | - | - | DA-3 이후 |
|
||||
| DA-6: 관계도 | done | - | - | - | DA-3 이후 |
|
||||
| DA-7: 프로세스 | done | - | - | - | DA-3 이후 |
|
||||
| DA-8: 강조 인용 | done | - | - | - | DA-3 이후 |
|
||||
| DA-9: 결론 바 | done | - | - | - | DA-3 이후 |
|
||||
| DA-10: 비교 테이블 | done | - | - | - | DA-3 이후 |
|
||||
| DA-11: 슬라이드 조합 렌더러 | done | - | - | - | DA-4~10 이후 |
|
||||
|
||||
## Phase 3: AI 파이프라인 연결
|
||||
|
||||
| 태스크 | 상태 | 담당 | 시작 | 완료 | 메모 |
|
||||
|--------|------|------|------|------|------|
|
||||
| DA-12: Kei API 연동 (Opus) | done | - | - | - | DA-2 이후 |
|
||||
| DA-13: 디자인 팀장 — 레이아웃 컨셉만 | todo | - | - | - | 기존에서 텍스트 정리 제거. 컨셉만 반환 |
|
||||
| DA-13b: 텍스트 편집자 (Kei 역할) | todo | - | - | - | 신규. 도메인 전문가로 슬롯 텍스트 정리 |
|
||||
| DA-14: 전체 파이프라인 (3단계) | todo | - | - | - | 분류→컨셉→텍스트→렌더링. 다중 페이지 |
|
||||
|
||||
## Phase 4: UI + 출력
|
||||
|
||||
| 태스크 | 상태 | 담당 | 시작 | 완료 | 메모 |
|
||||
|--------|------|------|------|------|------|
|
||||
| DA-15: 프론트엔드 | done | - | - | - | DA-14 이후. HTML 다운로드만 |
|
||||
| DA-16: 통합 테스트 | done | - | - | - | DA-15 이후 |
|
||||
|
||||
---
|
||||
|
||||
## 버그 수정 이력
|
||||
|
||||
### BF-1: 프론트엔드 SSE 파싱 실패 [발견: DA-15 이후]
|
||||
- **현상:** 서버는 정상 응답하지만 브라우저에서 결과 미표시. "시작 중..." 고정.
|
||||
- **원인:** main.py Python 문자열 안에 HTML/JS를 넣어서 `\n`이 실제 줄바꿈으로 변환 → JS `split('\n\n')` 깨짐. 또한 Windows SSE가 `\r\n\r\n`(CRLF)로 구분.
|
||||
- **해결:** static/index.html 별도 파일로 분리. FileResponse로 서빙. SSE split을 `/\r?\n\r?\n/` 정규식으로 변경.
|
||||
- **기술:** FileResponse (FastAPI 내장), 추가 의존성 0
|
||||
- **충돌 검토:** API 경로와 충돌 없음. 기존 코드 변경 없음. Kei persona 무관.
|
||||
- **상태:** done
|
||||
|
||||
### BF-2: 블록 내용이 비어있음 (Jinja2 include 변수 전달 실패) [발견: BF-1 이후]
|
||||
- **현상:** 슬라이드 HTML은 생성되지만 모든 블록 텍스트가 비어있음. 레이아웃 구조만 있고 내용 없음.
|
||||
- **원인:** renderer.py에서 Jinja2 `include`로 블록 템플릿을 삽입하는데, `include`는 블록별 변수를 개별 전달하지 못함. Sonnet이 채운 data가 템플릿에 도달 안 함.
|
||||
- **해결:** `include` 대신 각 블록 템플릿을 `env.get_template().render(**data)`로 개별 렌더링 후 완성된 HTML을 삽입. `render_standalone_block()`이 이미 이 방식으로 동작 중 → 통일.
|
||||
- **기술:** Jinja2 `get_template().render()` (내장), 추가 의존성 0
|
||||
- **수정 파일:** renderer.py, templates/slide-base.html
|
||||
- **충돌 검토:** 블록 템플릿 7개 변경 없음. pipeline.py 호출 시그니처 동일. Kei persona 무관.
|
||||
- **상태:** done
|
||||
|
||||
### BF-3: 한글 깨짐 (다운로드 HTML 파일) [발견: BF-1 이후]
|
||||
- **현상:** 다운로드한 HTML 파일에서 한글이 `ê±´ì¤ì°ì` 같은 깨진 문자로 표시.
|
||||
- **원인:** Blob 다운로드 시 UTF-8 BOM 미포함. 일부 에디터/브라우저가 인코딩 자동 감지 실패.
|
||||
- **해결:** download() 함수에서 Blob 생성 시 UTF-8 BOM(`'\uFEFF'`) 접두사 추가.
|
||||
- **기술:** JavaScript BOM 1줄, 추가 의존성 0
|
||||
- **수정 파일:** static/index.html
|
||||
- **충돌 검토:** 미리보기(iframe)에 영향 없음. SSE 파싱에 영향 없음.
|
||||
- **상태:** done
|
||||
|
||||
## 블로킹 이슈
|
||||
|
||||
없음
|
||||
|
||||
---
|
||||
|
||||
## 완료된 준비 사항
|
||||
|
||||
| 항목 | 파일 | 상태 |
|
||||
|------|------|------|
|
||||
| 프로젝트 규칙 | CLAUDE.md | 완료 |
|
||||
| 실행 계획 | PLAN.md | 완료 |
|
||||
| 진행 추적 | PROGRESS.md | 완료 (이 파일) |
|
||||
| 기술 조사 | docs/RESEARCH.md | 완료 |
|
||||
| 폴더 구조 | templates/, samples/, docs/ | 생성 완료 |
|
||||
388
docs/FIGMA-COMPONENT-EXTRACTION-PLAN.md
Normal file
388
docs/FIGMA-COMPONENT-EXTRACTION-PLAN.md
Normal file
@@ -0,0 +1,388 @@
|
||||
# Figma → 컴포넌트 추출 + 카탈로그 구축 계획
|
||||
|
||||
## 목적
|
||||
|
||||
Figma 디자인(바론컨설턴트 홈페이지 기획팀 공유)에서 재사용 가능한 슬라이드 콘텐츠 블록을 추출하고, 디자인 팀장(Sonnet)이 선택할 수 있는 카탈로그로 체계화한다.
|
||||
|
||||
---
|
||||
|
||||
## 현재 상태
|
||||
|
||||
### 보유 자산
|
||||
|
||||
| 항목 | 상태 | 위치 |
|
||||
|------|------|------|
|
||||
| Figma API 접근 | ✅ 가능 | Token: `.env` |
|
||||
| 기존 블록 템플릿 7종 | ✅ 완성 | `templates/blocks/` |
|
||||
| 디자인 토큰 | ✅ 완성 | `static/tokens.css` |
|
||||
| 슬라이드 렌더러 | ✅ 완성 | `src/renderer.py` |
|
||||
| 디자인 팀장 (DA-13) | ❌ todo | `src/design_director.py` |
|
||||
| 블록 카탈로그 | ❌ 없음 | - |
|
||||
|
||||
### Figma 파일 구조
|
||||
|
||||
```
|
||||
바론 공유 2025.05.13 (node: 1574-6254)
|
||||
├── 1장 바론컨설턴트
|
||||
├── 2장 디지털전환
|
||||
│ ├── 2-1 건설산업에서의 디지털전환 (1920x8538, 스크롤형)
|
||||
│ ├── 2-2 디지털전환과 소프트웨어 (1920x9123, 스크롤형)
|
||||
│ └── 건설산업에서의 디지털전환 (1920x8536, 스크롤형)
|
||||
│ [자세히보기]
|
||||
│ ├── 2-1장 자세히보기 (4프레임: 건설산업/BIM/GIS/디지털트윈)
|
||||
│ ├── 2-2장 자세히보기
|
||||
│ └── 2-3장 자세히보기
|
||||
├── 3장 제공서비스
|
||||
│ ├── 3-1장 솔루션프로그램 자세히보기
|
||||
│ └── 3-3장 빅룸 자세히보기
|
||||
└── 모션작업
|
||||
```
|
||||
|
||||
### 기존 블록 vs Figma에서 발견된 패턴
|
||||
|
||||
| 패턴 | 기존 블록 | Figma에서 발견 | 갭 |
|
||||
|------|----------|--------------|-----|
|
||||
| 2단 비교 | ✅ comparison | ✅ | - |
|
||||
| 카드 그리드 | ✅ card-grid | ✅ (변형 다수) | 변형 추가 필요 |
|
||||
| 벤 다이어그램 | ✅ relationship | ✅ | - |
|
||||
| 단계 흐름 | ✅ process | ✅ | - |
|
||||
| 강조 인용 | ✅ quote-block | ✅ (큰따옴표 장식) | 변형 추가 필요 |
|
||||
| 결론 바 | ✅ conclusion-bar | ✅ | - |
|
||||
| 비교 테이블 | ✅ comparison-table | ✅ | - |
|
||||
| **이미지 갤러리** | ❌ | ✅ (2열, 3열, 2x2) | **신규** |
|
||||
| **타임라인** | ❌ | ✅ (세로 원형 4단계) | **신규** |
|
||||
| **섹션 타이틀** | ❌ | ✅ (영문+한글 공통 헤더) | **신규** |
|
||||
| **사례 카드** | ❌ | ✅ (출처+불릿 카드) | **신규** |
|
||||
| **핵심 지표** | ❌ (정의만) | ✅ (큰 숫자+보조) | **신규** |
|
||||
| **아이콘 리스트** | ❌ | ✅ (아이콘+제목+설명) | **신규** |
|
||||
| **Hero 섹션** | ❌ | ✅ (배경+원형이미지+텍스트) | **신규** |
|
||||
| **CTA 버튼 바** | ❌ | ✅ (자세히보기 버튼) | **필요 시** |
|
||||
|
||||
---
|
||||
|
||||
## 작업 계획
|
||||
|
||||
### Phase A: Figma 분석 + 패턴 추출
|
||||
|
||||
#### A-1: Figma 전체 섹션 이미지 렌더링
|
||||
- **작업:** 각 섹션/프레임을 이미지로 렌더링하여 시각적으로 패턴 식별
|
||||
- **방법:** Figma API `/v1/images/{file_key}?ids={node_ids}`
|
||||
- **산출물:** `docs/figma-screenshots/` 폴더에 PNG 저장
|
||||
- **완료 기준:** 모든 자세히보기 프레임(8개)의 스크린샷 확보
|
||||
|
||||
#### A-2: Figma 노드 구조 심층 분석
|
||||
- **작업:** 각 프레임의 depth=5 수준까지 노드 트리 분석
|
||||
- **방법:** Figma API `/v1/files/{key}/nodes?ids={ids}&depth=5`
|
||||
- **추출 정보:**
|
||||
- TEXT 노드: 폰트, 크기, 색상, 내용
|
||||
- FRAME/GROUP: 레이아웃 방식 (auto-layout, constraints)
|
||||
- RECTANGLE: 배경색, 테두리, 둥근 모서리
|
||||
- INSTANCE: 재사용 컴포넌트 식별
|
||||
- **산출물:** `docs/figma-analysis/` 폴더에 구조 문서
|
||||
|
||||
#### A-3: 디자인 패턴 분류 + 명명
|
||||
- **작업:** 추출된 시각 요소를 재사용 가능한 블록 단위로 분류
|
||||
- **기준:**
|
||||
- 2회 이상 반복되는 패턴 → 블록 후보
|
||||
- 슬롯(교체 가능한 위치)이 명확한 것 → 우선 순위 높음
|
||||
- 콘텐츠 유형과 매칭되는 것 → 우선 순위 높음
|
||||
- **산출물:** 패턴 목록 + 각 패턴의 Figma 원본 노드 ID
|
||||
|
||||
### Phase B: HTML/CSS 컴포넌트 제작
|
||||
|
||||
#### B-1: 신규 블록 템플릿 제작 (6~8종)
|
||||
- **파일:** `templates/blocks/{name}.html`
|
||||
- **제작 순서 (우선순위):**
|
||||
1. `section-title.html` — 공통 헤더 (모든 슬라이드에서 사용)
|
||||
2. `example-card.html` — 사례 카드 (출처+불릿, 정책 문서 인용)
|
||||
3. `image-gallery.html` — 이미지 갤러리 (2~4장, 근거 자료)
|
||||
4. `timeline.html` — 타임라인 (세로/가로, 연혁/로드맵)
|
||||
5. `big-number.html` — 핵심 지표 (큰 숫자 + 보조 텍스트)
|
||||
6. `icon-list.html` — 아이콘 리스트 (아이콘+제목+설명, 기능 나열)
|
||||
- **규칙:**
|
||||
- 디자인 토큰(`var(--color-*)`) 사용 (하드코딩 색상 금지)
|
||||
- Jinja2 슬롯 (`{{ variable }}`) 형식
|
||||
- `<style>` 태그를 블록 HTML 안에 포함 (자체 완결)
|
||||
- Figma 원본과 시각적으로 유사하되 1:1 복제 아님 (디자인 토큰 기준)
|
||||
|
||||
#### B-2: 기존 블록 변형 추가
|
||||
- **대상:**
|
||||
- `quote-block.html` → `quote-block-decorated.html` (큰따옴표 ::before/::after 장식)
|
||||
- `card-grid.html` → `card-grid-icon.html` (아이콘 강조 변형)
|
||||
- `comparison.html` → `comparison-visual.html` (이미지 포함 비교)
|
||||
- **규칙:** 기존 슬롯 구조 유지, CSS만 변형
|
||||
|
||||
#### B-3: slide-base.html 업데이트
|
||||
- **내용:** 신규 블록의 grid-area 지원, 디자인 토큰 추가 (필요 시)
|
||||
- **주의:** 기존 7개 블록의 렌더링이 깨지지 않는지 반드시 검증
|
||||
|
||||
### Phase C: 카탈로그 구축
|
||||
|
||||
#### C-1: catalog.yaml 생성
|
||||
- **파일:** `templates/catalog.yaml`
|
||||
- **구조:**
|
||||
|
||||
```yaml
|
||||
version: "1.0"
|
||||
blocks:
|
||||
# --- 기존 블록 ---
|
||||
- id: quote-block
|
||||
name: 강조 인용
|
||||
visual: "좌측 컬러 라인 + 배경색 + 인용 텍스트"
|
||||
when: "문제 제기, 핵심 주장, 정의 강조할 때"
|
||||
not_for: "일반 설명문, 사례 나열"
|
||||
slots:
|
||||
required: [quote_text]
|
||||
optional: [source]
|
||||
character_limits:
|
||||
quote_text: 150
|
||||
source: 50
|
||||
variations: [default, decorated]
|
||||
figma_ref: null # 기존 블록은 Figma 이전에 제작됨
|
||||
|
||||
# --- 신규 블록 ---
|
||||
- id: example-card
|
||||
name: 사례 카드
|
||||
visual: "제목 + 출처(기관/연도) + 불릿 목록, 테두리 박스. 2~3장 나란히."
|
||||
when: "정책 문서 인용, 법령/지침 사례, 출처가 명확한 근거 제시"
|
||||
not_for: "용어 정의, 일반 설명, 비교"
|
||||
slots:
|
||||
required: [items[].title, items[].bullets[]]
|
||||
optional: [items[].source_org, items[].source_year]
|
||||
character_limits:
|
||||
title: 30
|
||||
bullet: 60
|
||||
variations: [single, 2col, 3col]
|
||||
figma_ref: "1574:54586 > 2-1_01 > examples-row"
|
||||
|
||||
- id: image-gallery
|
||||
name: 이미지 갤러리
|
||||
visual: "이미지 2~4장 나란히 + 캡션, 중앙 정렬"
|
||||
when: "근거 자료 사진, 문서 표지, 현장 사진, 참고 이미지"
|
||||
not_for: "텍스트 콘텐츠, 다이어그램 (다이어그램은 relationship 사용)"
|
||||
slots:
|
||||
required: [images[].src, images[].alt]
|
||||
optional: [images[].caption]
|
||||
variations: [2col, 3col, 2x2]
|
||||
figma_ref: "1574:54586 > 2-1_03 > image grid"
|
||||
|
||||
- id: timeline
|
||||
name: 타임라인
|
||||
visual: "세로/가로 축 위에 원형 마커 + 연도/제목/설명"
|
||||
when: "연혁, 로드맵, 정책 시행 일정, 단계별 계획"
|
||||
not_for: "프로세스 흐름 (순서는 있지만 시간이 아닌 것은 process 사용)"
|
||||
slots:
|
||||
required: [events[].year, events[].title]
|
||||
optional: [events[].description]
|
||||
character_limits:
|
||||
title: 25
|
||||
description: 60
|
||||
variations: [vertical, horizontal]
|
||||
figma_ref: "1574:54586 > 2-1_01 > timeline"
|
||||
|
||||
- id: big-number
|
||||
name: 핵심 지표
|
||||
visual: "큰 숫자(2rem+) + 단위 + 보조 설명. 2~4개 나란히."
|
||||
when: "KPI, 통계, 목표 수치, 성과 지표"
|
||||
not_for: "텍스트 설명, 정의"
|
||||
slots:
|
||||
required: [metrics[].number, metrics[].label]
|
||||
optional: [metrics[].unit, metrics[].description]
|
||||
variations: [2col, 3col, 4col]
|
||||
figma_ref: null
|
||||
|
||||
- id: section-title
|
||||
name: 섹션 타이틀
|
||||
visual: "영문 소제목(작은 글씨) + 한글 대제목(큰 글씨), 하단 구분선"
|
||||
when: "모든 슬라이드 상단, 섹션 시작"
|
||||
not_for: "본문 콘텐츠 영역"
|
||||
slots:
|
||||
required: [title_ko]
|
||||
optional: [title_en, subtitle]
|
||||
figma_ref: "공통 > section_title 컴포넌트"
|
||||
|
||||
- id: icon-list
|
||||
name: 아이콘 리스트
|
||||
visual: "아이콘 + 제목 + 설명이 세로로 나열, 좌측 아이콘 정렬"
|
||||
when: "기능 나열, 특성 목록, 장점 리스트"
|
||||
not_for: "비교 (comparison 사용), 순서 (process 사용)"
|
||||
slots:
|
||||
required: [items[].icon, items[].title, items[].description]
|
||||
character_limits:
|
||||
title: 20
|
||||
description: 80
|
||||
variations: [vertical, horizontal, grid]
|
||||
figma_ref: null
|
||||
|
||||
layouts:
|
||||
- id: "65-35"
|
||||
name: "6.5:3.5 좌우 분할"
|
||||
grid_columns: "6.5fr 3.5fr"
|
||||
when: "좌측 메인 콘텐츠 + 우측 보조/정의"
|
||||
|
||||
- id: "50-50"
|
||||
name: "5:5 균등 분할"
|
||||
grid_columns: "1fr 1fr"
|
||||
when: "대등한 비교, 병렬 콘텐츠"
|
||||
|
||||
- id: "single"
|
||||
name: "단일 컬럼"
|
||||
grid_columns: "1fr"
|
||||
when: "프로세스 흐름, 타임라인, 단순 구조"
|
||||
|
||||
- id: "35-65"
|
||||
name: "3.5:6.5 좌우 분할"
|
||||
grid_columns: "3.5fr 6.5fr"
|
||||
when: "좌측 요약/네비게이션 + 우측 메인 콘텐츠"
|
||||
```
|
||||
|
||||
#### C-2: 카탈로그 → 디자인 팀장 프롬프트 연결
|
||||
- **파일:** `src/design_director.py` 수정
|
||||
- **방법:** `catalog.yaml` 로드 → 블록 목록을 시스템 프롬프트에 삽입
|
||||
- **프롬프트 구조:**
|
||||
```
|
||||
사용 가능한 블록:
|
||||
- quote-block: 좌측 컬러 라인 + 인용 텍스트. 문제 제기할 때 사용. 일반 설명문에는 부적합.
|
||||
- example-card: 제목+출처+불릿. 정책 사례 인용할 때 사용. 2~3장 나란히.
|
||||
- card-grid: 2~4열 카드. 용어 정의 여러 개 나열할 때 사용.
|
||||
...
|
||||
|
||||
사용 가능한 레이아웃:
|
||||
- 65-35: 좌측 메인 + 우측 보조. 메인 콘텐츠가 많을 때.
|
||||
- 50-50: 균등 비교. 대등한 내용일 때.
|
||||
...
|
||||
|
||||
위 블록과 레이아웃만 사용하여 배치해라. 목록에 없는 블록은 만들지 마라.
|
||||
```
|
||||
|
||||
#### C-3: renderer.py 업데이트
|
||||
- **내용:** 신규 블록 템플릿 로드 지원
|
||||
- **주의:** 기존 `BLOCK_SLOTS` dict에 신규 블록 추가
|
||||
|
||||
---
|
||||
|
||||
## 충돌 지점 + 리스크 검토
|
||||
|
||||
### 1. Figma 디자인 ≠ 디자인 토큰
|
||||
|
||||
| 리스크 | 설명 | 대응 |
|
||||
|--------|------|------|
|
||||
| **색상 불일치** | Figma는 파란 그라데이션 배경 사용, 디자인 토큰은 `#2563eb` 단색 | Figma 색상을 참고하되 토큰 체계 우선. 필요 시 토큰 추가 (`--color-accent-light` 등) |
|
||||
| **폰트 불일치** | Figma는 특정 폰트(웹폰트 아닐 수 있음), 토큰은 Pretendard | Pretendard 유지. Figma 폰트 크기 비율만 참고 |
|
||||
| **여백 불일치** | Figma 920px 프레임 vs 슬라이드 1280px | 비율 기반으로 변환 (920:1280 = 0.72배) |
|
||||
|
||||
**원칙:** Figma 디자인을 1:1 복제하지 않는다. 패턴(구조)만 추출하고, 스타일은 디자인 토큰으로 통일한다.
|
||||
|
||||
### 2. 블록 개수 증가 → 디자인 팀장 혼란
|
||||
|
||||
| 리스크 | 설명 | 대응 |
|
||||
|--------|------|------|
|
||||
| **선택지 과다** | 7개 → 13~15개로 증가 시 Sonnet이 부적절한 블록 선택 가능 | `when` + `not_for` 필드로 선택 기준 명확화. 프롬프트에 "이런 콘텐츠에는 이 블록을 쓰지 마라" 명시 |
|
||||
| **유사 블록 혼동** | card-grid vs example-card vs icon-list 구분 | 카탈로그에 각 블록의 차이점 명시. 예: "card-grid는 정의, example-card는 출처 있는 사례, icon-list는 기능 나열" |
|
||||
|
||||
**대응:** catalog.yaml의 `not_for` 필드가 핵심. "이 블록은 이것에 쓰지 마라"를 명시해야 혼동 감소.
|
||||
|
||||
### 3. 기존 파이프라인 깨짐
|
||||
|
||||
| 리스크 | 설명 | 대응 |
|
||||
|--------|------|------|
|
||||
| **renderer.py 호환성** | 신규 블록 추가 시 기존 렌더링 깨짐 | 기존 7개 블록 테스트 먼저 통과 확인 후 신규 추가 |
|
||||
| **BLOCK_SLOTS 누락** | design_director.py의 BLOCK_SLOTS에 신규 블록 미등록 | catalog.yaml에서 자동 로드하는 방식으로 전환 |
|
||||
| **slide-base.html** | grid-template-areas에 신규 area명 미지원 | 동적 생성이므로 문제 없음 (Sonnet이 area명을 직접 지정) |
|
||||
|
||||
**대응:** Phase B 완료 후 기존 테스트 케이스(DA-16) 반드시 재실행.
|
||||
|
||||
### 4. Figma API 제약
|
||||
|
||||
| 리스크 | 설명 | 대응 |
|
||||
|--------|------|------|
|
||||
| **CSS 미제공** | Figma API는 CSS를 직접 제공하지 않음. 스타일 속성(fill, fontSize 등)만 제공 | 스타일 속성에서 CSS 수동 변환. 복잡한 것은 스크린샷 보고 직접 작성 |
|
||||
| **이미지 에셋** | 벡터(VECTOR, ELLIPSE)는 PNG로 렌더링 가능하나 CSS 재현 필요 | 단순 도형은 CSS로 재현, 복잡한 것은 PNG export 후 img 태그 |
|
||||
| **INSTANCE 참조** | Figma 컴포넌트(Instance)의 master 확인 필요 | `GET /v1/files/{key}/components`로 마스터 컴포넌트 조회 |
|
||||
| **Rate Limit** | Figma API rate limit 존재 | 한 번에 대량 호출 자제, 결과 캐싱 |
|
||||
|
||||
### 5. Starlight(.astro) 연결 시 충돌
|
||||
|
||||
| 리스크 | 설명 | 대응 |
|
||||
|--------|------|------|
|
||||
| **CSS 변수 충돌** | Starlight 자체 CSS 변수(`--sl-*`)와 디자인 토큰(`--color-*`) 충돌 | 네임스페이스 분리: `--da-color-*` 접두사 사용 검토 |
|
||||
| **폰트 로딩** | .astro에서 Pretendard CDN 로드 필요 | `<style is:global>`에 @import 포함 |
|
||||
| **`<style>` 인라인** | 현재 렌더러가 CSS를 인라인하는데, .astro에서 Starlight CSS와 섞임 | `.astro` 출력 시 scoped style 또는 `is:global` + 높은 specificity |
|
||||
|
||||
**대응:** .astro 출력은 Phase C 이후 별도 태스크로. 현재는 독립 HTML 출력에 집중.
|
||||
|
||||
### 6. 유사 프로젝트 사례에서 발견된 문제
|
||||
|
||||
| 사례 | 문제 | 교훈 |
|
||||
|------|------|------|
|
||||
| **SlideSpeak** | 16개 레이아웃으로 시작했으나 실제 콘텐츠 다양성 커버 못함 | 블록을 조합하는 방식이 고정 레이아웃보다 유연 (현재 방식 유지) |
|
||||
| **PPTAgent (EMNLP 2025)** | 레퍼런스 슬라이드 클러스터링 시 과소/과다 분류 문제 | 블록 수를 10~15개로 제한. 너무 세분화하면 AI 선택이 어려워짐 |
|
||||
| **Beautiful.ai** | 300개 템플릿 중 실제 사용은 20개 | 처음부터 많이 만들지 않기. 실제 사용 빈도 보고 추가 |
|
||||
| **InfoDesignLM** | 텍스트만으로 레이아웃 생성 시 콘텐츠 양 ↔ 공간 불일치 | character_limits를 카탈로그에 명시, 텍스트 편집자가 강제 준수 |
|
||||
|
||||
---
|
||||
|
||||
## 작업 순서 (의존 관계)
|
||||
|
||||
```
|
||||
A-1 (Figma 스크린샷) ──┐
|
||||
├→ A-3 (패턴 분류) → B-1 (신규 블록 제작) → B-3 (base 업데이트)
|
||||
A-2 (노드 구조 분석) ──┘ ↓
|
||||
C-1 (catalog.yaml)
|
||||
↓
|
||||
B-2 (변형 추가) → C-2 (팀장 프롬프트 연결)
|
||||
↓
|
||||
C-3 (renderer 업데이트)
|
||||
↓
|
||||
기존 테스트 재실행 (DA-16)
|
||||
```
|
||||
|
||||
### 예상 소요
|
||||
|
||||
| Phase | 작업 | 규모 |
|
||||
|-------|------|------|
|
||||
| A (분석) | Figma 스크린샷 + 노드 분석 + 패턴 분류 | 탐색/조사 |
|
||||
| B (제작) | 신규 블록 6~8종 + 변형 3종 + base 업데이트 | 구현 (핵심) |
|
||||
| C (카탈로그) | catalog.yaml + 팀장 프롬프트 + renderer 업데이트 | 연결/통합 |
|
||||
|
||||
**핵심 원칙:** Phase A에서 패턴을 정확히 분류하지 않으면 Phase B에서 쓸모없는 블록을 만들게 된다. 분석 먼저, 제작은 그 다음.
|
||||
|
||||
---
|
||||
|
||||
## 산출물 목록
|
||||
|
||||
```
|
||||
docs/
|
||||
├── figma-screenshots/ # A-1: 각 프레임 PNG
|
||||
├── figma-analysis/ # A-2: 노드 구조 문서
|
||||
└── FIGMA-COMPONENT-EXTRACTION-PLAN.md # 이 파일
|
||||
|
||||
templates/
|
||||
├── catalog.yaml # C-1: 블록 카탈로그 (AI용 + 사람용)
|
||||
├── blocks/
|
||||
│ ├── (기존 7개)
|
||||
│ ├── section-title.html # B-1: 신규
|
||||
│ ├── example-card.html # B-1: 신규
|
||||
│ ├── image-gallery.html # B-1: 신규
|
||||
│ ├── timeline.html # B-1: 신규
|
||||
│ ├── big-number.html # B-1: 신규
|
||||
│ ├── icon-list.html # B-1: 신규
|
||||
│ ├── quote-block-decorated.html # B-2: 변형
|
||||
│ ├── card-grid-icon.html # B-2: 변형
|
||||
│ └── comparison-visual.html # B-2: 변형
|
||||
└── slide-base.html # B-3: 업데이트
|
||||
|
||||
src/
|
||||
├── design_director.py # C-2: catalog.yaml 연동
|
||||
└── renderer.py # C-3: 신규 블록 지원
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 금지 사항
|
||||
|
||||
1. Figma 디자인을 1:1 복제하지 않는다 (패턴만 추출, 스타일은 토큰 기준)
|
||||
2. 기존 7개 블록 템플릿을 수정하지 않는다 (신규/변형은 별도 파일)
|
||||
3. 한 번에 모든 블록을 만들지 않는다 (A-3 분류 결과를 보고 우선순위 재조정)
|
||||
4. catalog.yaml 없이 블록을 추가하지 않는다 (카탈로그 미등록 = 디자인 팀장이 모름)
|
||||
5. Kei Persona Agent 코드를 수정하지 않는다
|
||||
686
docs/RESEARCH.md
Normal file
686
docs/RESEARCH.md
Normal file
@@ -0,0 +1,686 @@
|
||||
# Design Agent - Technology Research Report
|
||||
|
||||
Date: 2026-03-24
|
||||
|
||||
---
|
||||
|
||||
## 1. CSS Grid for Slide Layouts
|
||||
|
||||
### 1.1 Fixed-Viewport Approach (16:9, 1280x720)
|
||||
|
||||
**Recommended technique: Fixed container + CSS transform scaling.**
|
||||
|
||||
The slide container should be authored at a fixed "normal" size (1280x720), then scaled to fit any viewport using `transform: scale()`. This is the same approach used by reveal.js, the dominant HTML presentation framework.
|
||||
|
||||
```css
|
||||
.slide {
|
||||
width: 1280px;
|
||||
height: 720px;
|
||||
aspect-ratio: 16 / 9;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
```
|
||||
|
||||
For preview/embedding, wrap in a container that calculates a scale factor:
|
||||
```css
|
||||
.slide-wrapper {
|
||||
width: 100%;
|
||||
max-width: 1280px;
|
||||
aspect-ratio: 16 / 9;
|
||||
overflow: hidden;
|
||||
}
|
||||
.slide-wrapper > .slide {
|
||||
transform-origin: top left;
|
||||
transform: scale(var(--slide-scale, 1));
|
||||
}
|
||||
```
|
||||
|
||||
The scale factor can be computed with minimal JS: `containerWidth / 1280`.
|
||||
|
||||
**Key insight from reveal.js:** All presentations have a "normal" size at which they are authored. The framework automatically scales uniformly to fit different resolutions without changing aspect ratio or layout. Default is 960x700; for our use case, 1280x720 is the standard 16:9 HD dimension.
|
||||
|
||||
**Why this works for Design Agent:** The renderer produces HTML at exactly 1280x720. It never needs to be "responsive" -- it's a fixed-format document like a PDF page. Scaling is only for preview purposes.
|
||||
|
||||
### 1.2 Grid-Template-Areas for Block Combinations
|
||||
|
||||
`grid-template-areas` provides named regions that map directly to the block composition concept in the CLAUDE.md:
|
||||
|
||||
```css
|
||||
.layout-quote-compare-cards-conclusion {
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
"quote quote"
|
||||
"compare cards"
|
||||
"diagram diagram"
|
||||
"conclusion conclusion";
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-rows: auto 1fr auto auto;
|
||||
gap: var(--spacing-block);
|
||||
padding: var(--spacing-page);
|
||||
}
|
||||
```
|
||||
|
||||
**Best practice:** Define each layout as a separate CSS class with its own `grid-template-areas`. The Sonnet agent selects which layout class to apply based on the block combination it decides. This keeps the renderer deterministic -- it just applies the class.
|
||||
|
||||
**Reusability:** CSS variables allow the same grid template to adapt:
|
||||
- Column count: `grid-template-columns: repeat(var(--cols, 3), 1fr)`
|
||||
- Gap: `gap: var(--spacing-block)`
|
||||
- Row sizing: `grid-template-rows` can mix `auto` (content-sized) and `1fr` (fill remaining)
|
||||
|
||||
### 1.3 Design Tokens
|
||||
|
||||
**Naming convention:** `--{category}-{property}-{variant}`
|
||||
|
||||
The CLAUDE.md already defines a good token set. The industry standard approach (from EightShapes, Nord Design System) uses kebab-case with semantic naming:
|
||||
|
||||
```
|
||||
--color-primary, --color-accent, --color-neutral
|
||||
--font-title, --font-subtitle, --font-body, --font-caption
|
||||
--spacing-page, --spacing-block, --spacing-inner
|
||||
--radius, --border-width, --accent-border
|
||||
```
|
||||
|
||||
This matches what's already in the project's CLAUDE.md. No changes needed.
|
||||
|
||||
**For slide-specific tokens, add:**
|
||||
```css
|
||||
--slide-width: 1280px;
|
||||
--slide-height: 720px;
|
||||
--slide-aspect: 16 / 9;
|
||||
```
|
||||
|
||||
### 1.4 Overflow Handling in Fixed Pages
|
||||
|
||||
Three techniques for ensuring content fits within fixed dimensions:
|
||||
|
||||
1. **Single-line truncation:**
|
||||
```css
|
||||
.truncate {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
```
|
||||
|
||||
2. **Multi-line truncation (line clamping):**
|
||||
```css
|
||||
.line-clamp-3 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
```
|
||||
|
||||
3. **Container overflow hidden (safety net):**
|
||||
```css
|
||||
.block { overflow: hidden; }
|
||||
.slide { overflow: hidden; }
|
||||
```
|
||||
|
||||
**Korean-specific consideration:** `word-break: keep-all` affects how text wraps, which impacts line count. Content fitting calculations must account for this. The Sonnet agent should be instructed with character limits per slot, not word limits.
|
||||
|
||||
---
|
||||
|
||||
## 2. Content-to-Layout Classification
|
||||
|
||||
### 2.1 How to Prompt an LLM for Reliable Classification
|
||||
|
||||
**Claude Structured Output (recommended):**
|
||||
|
||||
Anthropic launched Structured Outputs in November 2025, supporting Claude Sonnet 4.5 and Opus 4.1. This guarantees JSON schema conformance at the token generation level -- the model literally cannot produce tokens that violate the schema.
|
||||
|
||||
Implementation:
|
||||
```python
|
||||
from pydantic import BaseModel, Field
|
||||
from anthropic import Anthropic
|
||||
|
||||
class ContentBlock(BaseModel):
|
||||
content_type: str = Field(description="One of: comparison, process, relationship, big-number, definition, list, timeline, emphasis, problem")
|
||||
evidence: str = Field(description="Which text patterns led to this classification")
|
||||
suggested_block: str = Field(description="Block type from the template library")
|
||||
|
||||
class ContentAnalysis(BaseModel):
|
||||
blocks: list[ContentBlock]
|
||||
layout_direction: str = Field(description="How blocks should be arranged on the page")
|
||||
primary_message: str = Field(description="The single key takeaway for this slide")
|
||||
```
|
||||
|
||||
**Reliability strategies:**
|
||||
- Set temperature to 0.0-0.1 for classification tasks (reduces format drift)
|
||||
- Use the `output_format` parameter with JSON schema (not just prompting)
|
||||
- Include one perfect example in the system prompt
|
||||
- Add explicit validation instructions
|
||||
|
||||
### 2.2 Information Type Taxonomy for Presentations
|
||||
|
||||
Based on research from SlideSpeak (16 layout types), PPTAgent (EMNLP 2025), Beautiful.ai (300 templates), and Dr. Andrew Abela's Chart Chooser:
|
||||
|
||||
**The CLAUDE.md already defines 9 excellent content types.** Here is how they map to industry precedent:
|
||||
|
||||
| CLAUDE.md Type | SlideSpeak Equivalent | PPTAgent Category | Common Slide Type |
|
||||
|---|---|---|---|
|
||||
| comparison | SS_ITEMS_*_A/B | Content slide (multi-column) | Comparison slide |
|
||||
| process | SS_STEPS_3/4/5 | Content slide (sequential) | Process/workflow slide |
|
||||
| relationship | (custom) | Content slide (diagram) | Venn/tree diagram slide |
|
||||
| big-number | SS_BIGNUMBER_1/3 | Content slide (metric) | KPI/statistics slide |
|
||||
| definition (card-grid) | SS_ITEMS_3/4/5/6 | Content slide (grid) | Definition/feature slide |
|
||||
| list | SS_CONTENT | Content slide (list) | Bullet point slide |
|
||||
| timeline | (custom) | Content slide (sequential) | Timeline slide |
|
||||
| emphasis (quote-block) | (custom) | Structural slide | Quote/callout slide |
|
||||
| problem | (custom) | Structural slide | Problem statement slide |
|
||||
|
||||
**Additional types to consider (from industry):**
|
||||
- **SWOT** (SlideSpeak has SS_SWOT) -- 4-quadrant grid
|
||||
- **Matrix/Table** (already covered by comparison-table)
|
||||
- **Cover/Title** -- for when the content is just a single title/subtitle
|
||||
|
||||
### 2.3 Structured Output Schema for Layout Decisions
|
||||
|
||||
The PPTAgent paper (EMNLP 2025) uses a two-stage approach that aligns perfectly with the Design Agent architecture:
|
||||
|
||||
- **Stage 1 (Opus):** Analyze content, classify into functional types, extract content schemas
|
||||
- **Stage 2 (Sonnet):** Select reference layouts, fill content into slots, apply editing actions
|
||||
|
||||
PPTAgent represents all parsed outputs in JSON format for LLM compatibility. The Design Agent should do the same.
|
||||
|
||||
---
|
||||
|
||||
## 3. Slot-Based Template Systems
|
||||
|
||||
### 3.1 SlideSpeak's Named Slot System
|
||||
|
||||
SlideSpeak uses a comprehensive naming convention for template placeholders:
|
||||
|
||||
**Layout names:** SS_COVER, SS_CONTENT, SS_TABLE_OF_CONTENT, SS_BIGNUMBER_1_A, SS_BIGNUMBER_3_A, SS_ITEMS_3_A through SS_ITEMS_6_B, SS_STEPS_3 through SS_STEPS_5_ICONS, SS_SWOT
|
||||
|
||||
**Universal slot names:**
|
||||
- `SS_TITLE` -- slide title
|
||||
- `SS_SUBTITLE` -- subtitle
|
||||
- `SS_LOGO` -- logo placeholder
|
||||
- `SS_IMAGE` -- general image
|
||||
- `SS_PAGE` -- page number
|
||||
- `SS_PRESENTATION_TITLE` -- footer title
|
||||
|
||||
**Multi-item slot naming pattern:**
|
||||
- `SS_ITEM_{N}_TITLE` -- title for item N
|
||||
- `SS_ITEM_{N}_CONTENT` -- content for item N
|
||||
- `SS_ITEM_{N}_NUMBER` -- number for item N (big-number layouts)
|
||||
- `SS_ICON_{N}` -- icon for item N
|
||||
|
||||
**Key insight for Design Agent:** The `{{SLOT_NAME}}` convention in CLAUDE.md maps well. Adopt a similar systematic naming: `{{BLOCK_TITLE}}`, `{{ITEM_1_TITLE}}`, `{{ITEM_1_CONTENT}}`, etc.
|
||||
|
||||
### 3.2 Jinja2 for Template Rendering
|
||||
|
||||
Jinja2 is the recommended engine. It integrates natively with FastAPI and Python.
|
||||
|
||||
**Block inheritance for base layout:**
|
||||
```jinja2
|
||||
{# base_slide.html #}
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<link rel="stylesheet" href="design-tokens.css">
|
||||
<style>{% block extra_style %}{% endblock %}</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="slide">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
**Block-level templates:**
|
||||
```jinja2
|
||||
{# blocks/comparison.html #}
|
||||
<div class="block-comparison">
|
||||
<div class="col-left">
|
||||
<h3>{{ left_title }}</h3>
|
||||
<p>{{ left_content }}</p>
|
||||
</div>
|
||||
<div class="col-right">
|
||||
<h3>{{ right_title }}</h3>
|
||||
<p>{{ right_content }}</p>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
**Composition via includes:**
|
||||
```jinja2
|
||||
{# Generated by renderer based on Sonnet's layout decision #}
|
||||
{% extends "base_slide.html" %}
|
||||
{% block content %}
|
||||
{% include "blocks/quote-block.html" %}
|
||||
<div class="grid-row-2">
|
||||
{% include "blocks/comparison.html" %}
|
||||
{% include "blocks/card-grid.html" %}
|
||||
</div>
|
||||
{% include "blocks/conclusion-bar.html" %}
|
||||
{% endblock %}
|
||||
```
|
||||
|
||||
### 3.3 Slot Constraints
|
||||
|
||||
Each slot should have defined constraints that Sonnet respects:
|
||||
|
||||
| Slot Type | Max Characters (Korean) | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| slide_title | 30 | Yes | Single line |
|
||||
| block_title | 20 | Yes | Single line |
|
||||
| item_title | 15 | Yes | Single line |
|
||||
| item_content | 80 | No | 2-3 lines |
|
||||
| quote_text | 120 | Yes | 3-4 lines |
|
||||
| big_number | 8 | Yes | Number + unit |
|
||||
| conclusion | 60 | Yes | Single line |
|
||||
| caption | 40 | No | Single line |
|
||||
|
||||
**Korean consideration:** Korean characters are roughly 2x the width of Latin characters at the same font size. Character limits should be specified in characters, not words, since Korean doesn't use spaces the same way as English.
|
||||
|
||||
---
|
||||
|
||||
## 4. HTML to PDF Conversion
|
||||
|
||||
### 4.1 Playwright (Recommended)
|
||||
|
||||
**Why Playwright over Puppeteer:**
|
||||
- Native Python SDK (no Node.js dependency for a Python project)
|
||||
- Multiple browser support (Chromium, Firefox, WebKit), though PDF only works in Chromium
|
||||
- Growing community, active maintenance, better CI/CD integration
|
||||
- Full CSS Grid support via real Chromium rendering engine
|
||||
|
||||
**Python implementation:**
|
||||
```python
|
||||
from playwright.async_api import async_playwright
|
||||
|
||||
async def html_to_pdf(html_content: str, output_path: str) -> None:
|
||||
async with async_playwright() as p:
|
||||
browser = await p.chromium.launch()
|
||||
page = await browser.new_page()
|
||||
await page.set_content(html_content, wait_until="networkidle")
|
||||
await page.pdf(
|
||||
path=output_path,
|
||||
width="1280px",
|
||||
height="720px",
|
||||
print_background=True,
|
||||
prefer_css_page_size=True,
|
||||
)
|
||||
await browser.close()
|
||||
```
|
||||
|
||||
**Key options:**
|
||||
- `print_background=True` -- required for background colors/images
|
||||
- `prefer_css_page_size=True` -- lets CSS `@page` rules control dimensions
|
||||
- `width`/`height` -- custom page dimensions (accepts px, in, mm, cm units)
|
||||
|
||||
### 4.2 Print CSS for Slide Format
|
||||
|
||||
```css
|
||||
@media print {
|
||||
@page {
|
||||
size: 1280px 720px;
|
||||
margin: 0;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
.slide {
|
||||
width: 1280px;
|
||||
height: 720px;
|
||||
page-break-after: always;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**`-webkit-print-color-adjust: exact`** is critical -- without it, background colors and images may be stripped in PDF output.
|
||||
|
||||
### 4.3 Quality Comparison
|
||||
|
||||
Both Puppeteer and Playwright use Chromium's print-to-PDF engine, so output quality is identical. The choice comes down to:
|
||||
|
||||
| Factor | Playwright | Puppeteer |
|
||||
|---|---|---|
|
||||
| Language | Python, JS, C#, Java | JS/Node.js only |
|
||||
| PDF engine | Chromium only | Chromium only |
|
||||
| CSS Grid quality | Excellent (Chromium) | Excellent (Chromium) |
|
||||
| Korean font rendering | Excellent | Excellent |
|
||||
| Install size | ~400MB (browser binary) | ~300MB |
|
||||
| API ergonomics | Better async patterns | More established |
|
||||
|
||||
**Recommendation:** Playwright, because the Design Agent backend is Python. No need to bridge to Node.js.
|
||||
|
||||
### 4.4 Korean-Specific Considerations
|
||||
|
||||
- Fonts must be available on the server. Self-host Pretendard/Noto Sans KR WOFF2 files or use CDN.
|
||||
- Set `lang="ko"` on the HTML element for proper line-breaking algorithms.
|
||||
- Ensure `@font-face` declarations are loaded before PDF generation (`wait_until="networkidle"`).
|
||||
|
||||
---
|
||||
|
||||
## 5. Pure CSS Diagrams
|
||||
|
||||
### 5.1 Venn Diagrams (Pure CSS)
|
||||
|
||||
**Technique:** Overlapping circles with opacity and negative margins.
|
||||
|
||||
```css
|
||||
.venn-container { display: flex; align-items: center; justify-content: center; }
|
||||
.venn-circle {
|
||||
width: 200px; height: 200px;
|
||||
border-radius: 50%;
|
||||
opacity: 0.7;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
.venn-a { background: var(--color-accent); }
|
||||
.venn-b { background: var(--color-neutral); margin-left: -60px; }
|
||||
```
|
||||
|
||||
**Advanced approach (Adrian Roselli):** CSS Grid + `shape-outside` for text wrapping within overlapping regions. More complex but better for text-heavy Venn diagrams.
|
||||
|
||||
**Limitation:** Pure CSS Venn diagrams work well for 2-3 circles. Beyond that, SVG is more practical.
|
||||
|
||||
### 5.2 Flowcharts / Process Arrows (Pure CSS)
|
||||
|
||||
**Technique:** Flexbox/Grid layout + pseudo-elements for arrows.
|
||||
|
||||
```css
|
||||
.process-steps { display: flex; align-items: center; gap: 0; }
|
||||
.process-step {
|
||||
background: var(--color-bg-subtle);
|
||||
padding: var(--spacing-inner);
|
||||
position: relative;
|
||||
flex: 1;
|
||||
}
|
||||
.process-step + .process-step::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -12px; top: 50%;
|
||||
transform: translateY(-50%);
|
||||
border: 8px solid transparent;
|
||||
border-left-color: var(--color-accent);
|
||||
}
|
||||
```
|
||||
|
||||
**CSS Anchor Positioning (2025-2026):** A new CSS feature for connecting elements with lines. Supported in Chrome 125+, Safari 26+, not yet in Firefox. Since we target Chromium (for PDF generation), this is usable but adds complexity. For the Design Agent, pseudo-element arrows are simpler and more reliable.
|
||||
|
||||
### 5.3 Tree/Hierarchy Diagrams (Pure CSS)
|
||||
|
||||
**Technique:** Nested `<ul>/<li>` + pseudo-elements for connector lines.
|
||||
|
||||
Libraries:
|
||||
- **Treeflex** (https://dumptyd.github.io/treeflex/) -- CSS-only library for hierarchy trees, no JS
|
||||
- Custom implementation using `border-left` on `<li>` for vertical lines, `::before` for horizontal connectors, `::after` for node circles
|
||||
|
||||
### 5.4 When to Use SVG Instead
|
||||
|
||||
| Use Case | CSS | SVG | Recommendation |
|
||||
|---|---|---|---|
|
||||
| Venn (2-3 circles) | Good | Better | CSS for simplicity |
|
||||
| Process arrows (linear) | Excellent | Overkill | CSS |
|
||||
| Tree (2-3 levels) | Good | Better | CSS (Treeflex) |
|
||||
| Complex flowchart (branches) | Difficult | Much better | SVG |
|
||||
| Curved connectors | Impossible | Easy | SVG |
|
||||
| Data-driven charts | Impossible | Required | SVG |
|
||||
| Accessible diagrams | Poor | Excellent | SVG |
|
||||
|
||||
**Recommendation for Design Agent:** Use pure CSS for simple diagrams (process arrows, 2-circle Venn, basic tree). Generate inline SVG for anything more complex. Since the renderer produces static HTML for PDF export, there's no JS concern.
|
||||
|
||||
**Accessibility note:** SVG has built-in accessibility elements (`<title>`, `<desc>`, `aria-*`). For the Design Agent's output (primarily visual slides for PDF), this is less critical but good practice.
|
||||
|
||||
---
|
||||
|
||||
## 6. Korean Typography in CSS
|
||||
|
||||
### 6.1 Font Selection
|
||||
|
||||
**Primary recommendation: Pretendard**
|
||||
|
||||
- Modern system-ui replacement font designed for cross-platform use
|
||||
- Built on Inter (Latin) + Source Han Sans (CJK) + M PLUS 1p
|
||||
- 9 weights + variable font support
|
||||
- Dynamic subset via CDN (Google Fonts-style loading for Korean)
|
||||
- Most popular Korean web font according to HTTP Archive 2024
|
||||
|
||||
**CDN options:**
|
||||
```css
|
||||
/* Dynamic subset (recommended for web) */
|
||||
@import url('https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css');
|
||||
|
||||
/* Or from cdnjs */
|
||||
@import url('https://cdnjs.cloudflare.com/ajax/libs/pretendard/1.3.9/variable/pretendardvariable-dynamic-subset.min.css');
|
||||
```
|
||||
|
||||
**For PDF generation (self-hosted):**
|
||||
Download WOFF2 files and serve locally to avoid CDN dependency during headless browser PDF generation.
|
||||
|
||||
**Fallback stack:**
|
||||
```css
|
||||
font-family: 'Pretendard Variable', 'Pretendard', -apple-system, 'Noto Sans KR',
|
||||
'Malgun Gothic', sans-serif;
|
||||
```
|
||||
|
||||
**Alternative: Noto Sans KR**
|
||||
- Google Fonts native, widest browser support
|
||||
- Available via Google Fonts CDN with automatic Korean subsetting
|
||||
- Good for when Pretendard is not available
|
||||
|
||||
### 6.2 Line-Height and Letter-Spacing
|
||||
|
||||
**W3C KLREQ (Korean Layout Requirements) recommendations:**
|
||||
|
||||
| Property | Value | Rationale |
|
||||
|---|---|---|
|
||||
| `line-height` | 1.6-1.8 | CJK text needs ~1.7 (vs 1.2-1.5 for Latin) due to higher information density per character |
|
||||
| `letter-spacing` | 0 to -0.02em | Korean text looks best with tight or default spacing. Avoid positive letter-spacing. |
|
||||
| `word-spacing` | normal | Korean uses spaces between words (unlike Japanese/Chinese) |
|
||||
|
||||
**For slides specifically:**
|
||||
```css
|
||||
body {
|
||||
line-height: 1.7;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
h1, h2, h3 {
|
||||
line-height: 1.3;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
```
|
||||
|
||||
### 6.3 Word-Break Rules
|
||||
|
||||
```css
|
||||
/* Korean text: keep syllable blocks together */
|
||||
body {
|
||||
word-break: keep-all;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
```
|
||||
|
||||
**`word-break: keep-all`** prevents line breaks within Korean syllable blocks (e.g., "한글" won't break as "한" / "글" mid-word). This is essential for readable Korean typography.
|
||||
|
||||
**`overflow-wrap: break-word`** is the safety net for extremely long strings (URLs, technical terms) that might overflow.
|
||||
|
||||
**Browser support:** All modern browsers support `keep-all` since 2016. There is ongoing W3C discussion about a potential `keep-all-hangul` value that would apply `keep-all` only to Hangul characters and `normal` to everything else.
|
||||
|
||||
### 6.4 Mixing Korean + English
|
||||
|
||||
**Key challenge:** At the same point size, Latin letters appear smaller than Korean characters.
|
||||
|
||||
**Solutions:**
|
||||
1. Use a font designed for mixed text (Pretendard handles this well, being built on Inter + Source Han Sans)
|
||||
2. Set the Latin font first in `font-family` stack, CJK font second (most CJK fonts include Latin glyphs, but their Latin is often inferior)
|
||||
3. No need for `font-size-adjust` if using Pretendard (it's designed for optical balance between scripts)
|
||||
|
||||
**Punctuation:** Korean uses proportional-width punctuation (like Latin), unlike Japanese/Chinese which use full-width. Pretendard handles this correctly.
|
||||
|
||||
### 6.5 Language Attribute
|
||||
|
||||
Always set `lang="ko"` on the HTML element:
|
||||
```html
|
||||
<html lang="ko">
|
||||
```
|
||||
This enables the browser's Korean-specific line-breaking algorithms and font selection.
|
||||
|
||||
---
|
||||
|
||||
## 7. FastAPI Integration
|
||||
|
||||
### 7.1 Serving the Design Agent
|
||||
|
||||
The Design Agent should be a FastAPI application with these endpoints:
|
||||
|
||||
| Endpoint | Method | Purpose |
|
||||
|---|---|---|
|
||||
| `/api/analyze` | POST | Step 1: Send content to Opus for classification |
|
||||
| `/api/generate` | POST | Steps 2-3: Sonnet selects + renderer produces HTML |
|
||||
| `/api/generate/stream` | GET (SSE) | Steps 1-3 with real-time progress |
|
||||
| `/api/preview/{job_id}` | GET | Return generated HTML for iframe preview |
|
||||
| `/api/download/{job_id}/pdf` | GET | Generate and return PDF |
|
||||
| `/api/download/{job_id}/html` | GET | Return HTML file |
|
||||
| `/api/templates` | GET | List available block templates |
|
||||
|
||||
### 7.2 SSE Streaming
|
||||
|
||||
FastAPI has native SSE support (added to official docs). Two library options:
|
||||
|
||||
**Option A: sse-starlette (recommended)**
|
||||
- Production-ready, W3C SSE spec compliant
|
||||
- Already used in the HWPX project
|
||||
- `pip install sse-starlette`
|
||||
|
||||
**Option B: fastapi-sse**
|
||||
- Lighter weight, built specifically for FastAPI
|
||||
- Supports sending Pydantic models as SSE events
|
||||
- `pip install fastapi-sse`
|
||||
|
||||
**Implementation:**
|
||||
```python
|
||||
from sse_starlette.sse import EventSourceResponse
|
||||
|
||||
@router.get("/api/generate/stream")
|
||||
async def stream_generation(content: str):
|
||||
async def event_generator():
|
||||
yield {"event": "step_start", "data": "analyzing"}
|
||||
# ... Opus classification
|
||||
yield {"event": "step_complete", "data": json.dumps(analysis)}
|
||||
|
||||
yield {"event": "step_start", "data": "selecting"}
|
||||
# ... Sonnet selection
|
||||
yield {"event": "step_complete", "data": json.dumps(slots)}
|
||||
|
||||
yield {"event": "step_start", "data": "rendering"}
|
||||
# ... CSS Grid rendering
|
||||
yield {"event": "complete", "data": job_id}
|
||||
|
||||
return EventSourceResponse(event_generator())
|
||||
```
|
||||
|
||||
### 7.3 File Upload
|
||||
|
||||
```python
|
||||
from fastapi import UploadFile, File
|
||||
|
||||
@router.post("/api/upload")
|
||||
async def upload_content(file: UploadFile = File(...)):
|
||||
# Read and extract text from uploaded file
|
||||
text = await extract_text(file)
|
||||
return {"text": text, "filename": file.filename}
|
||||
```
|
||||
|
||||
### 7.4 Static File Serving for Preview
|
||||
|
||||
**For development:** FastAPI's `StaticFiles` mount for serving generated HTML:
|
||||
```python
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
app.mount("/static", StaticFiles(directory="output"), name="static")
|
||||
```
|
||||
|
||||
**For production:** Serve HTML via API response body (like the HWPX project does with `<iframe srcDoc>`). This is more secure -- no direct file path exposure.
|
||||
|
||||
**Font serving:** Self-hosted Pretendard WOFF2 files should be served as static files for reliable PDF generation.
|
||||
|
||||
---
|
||||
|
||||
## Summary: Technology Stack Recommendation
|
||||
|
||||
| Component | Technology | Version | Rationale |
|
||||
|---|---|---|---|
|
||||
| LLM (Analysis) | Claude Opus via Anthropic API | Structured Outputs beta | Content classification, layout direction |
|
||||
| LLM (Selection) | Claude Sonnet via Anthropic API | Structured Outputs beta | Slot filling, content editing |
|
||||
| Template Engine | Jinja2 | >=3.1 | Native Python, FastAPI integration, block inheritance |
|
||||
| CSS Layout | CSS Grid + grid-template-areas | Native CSS | Named regions map to block composition |
|
||||
| Design Tokens | CSS Custom Properties | Native CSS | Already defined in CLAUDE.md |
|
||||
| Typography | Pretendard Variable | 1.3.9 | Best Korean web font, dynamic subset |
|
||||
| Fallback Font | Noto Sans KR | Latest | Google Fonts CDN backup |
|
||||
| PDF Generation | Playwright (Python) | >=1.40 | Native Python SDK, full CSS Grid support |
|
||||
| Web Framework | FastAPI | >=0.115 | SSE support, file upload, same as HWPX project |
|
||||
| SSE | sse-starlette | >=2.0 | Production-ready, W3C compliant |
|
||||
| CSS Diagrams | Pure CSS + inline SVG fallback | N/A | Pseudo-elements for simple, SVG for complex |
|
||||
| Slide Scaling | CSS transform: scale() | Native CSS | reveal.js-proven approach |
|
||||
| Korean Line Breaking | word-break: keep-all | Native CSS | W3C KLREQ recommendation |
|
||||
|
||||
---
|
||||
|
||||
## Key References
|
||||
|
||||
### CSS Grid & Layout
|
||||
- [CSS-Tricks Complete Guide to CSS Grid](https://css-tricks.com/complete-guide-css-grid-layout/)
|
||||
- [Smashing Magazine: Understanding CSS Grid Template Areas](https://www.smashingmagazine.com/2020/02/understanding-css-grid-template-areas/)
|
||||
- [MDN: aspect-ratio](https://developer.mozilla.org/en-US/docs/Web/CSS/aspect-ratio)
|
||||
- [Grid by Example](https://gridbyexample.com/examples/)
|
||||
- [reveal.js Presentation Size](https://revealjs.com/presentation-size/)
|
||||
|
||||
### Design Tokens
|
||||
- [CSS-Tricks: What Are Design Tokens?](https://css-tricks.com/what-are-design-tokens/)
|
||||
- [EightShapes: Naming Tokens in Design Systems](https://medium.com/eightshapes-llc/naming-tokens-in-design-systems-9e86c7444676)
|
||||
- [FrontendTools: CSS Variables Guide](https://www.frontendtools.tech/blog/css-variables-guide-design-tokens-theming-2025)
|
||||
- [Nord Design System: Naming](https://nordhealth.design/naming/)
|
||||
|
||||
### Content Classification & Presentation AI
|
||||
- [PPTAgent (EMNLP 2025)](https://arxiv.org/abs/2501.03936)
|
||||
- [SlideSpeak Layouts & Placeholders](https://docs.slidespeak.co/basics/custom-templates/layouts-and-placeholders)
|
||||
- [SlideSpeak Template Preparation](https://docs.slidespeak.co/basics/custom-templates/preparing)
|
||||
- [SlideModel: 12 Types of Slides](https://slidemodel.com/types-of-slides/)
|
||||
- [SlideUpLift: Types of Slides](https://slideuplift.com/blog/types-of-slides/)
|
||||
- [LLM-Powered Slide Decks Comparison](https://nbrosse.github.io/posts/llm-slides/llm-slides.html)
|
||||
|
||||
### Structured Output
|
||||
- [Anthropic Structured Outputs Docs](https://platform.claude.com/docs/en/build-with-claude/structured-outputs)
|
||||
- [PromptLayer: How JSON Schema Works for Structured Outputs](https://blog.promptlayer.com/how-json-schema-works-for-structured-outputs-and-tool-integration/)
|
||||
|
||||
### PDF Generation
|
||||
- [Playwright Python page.pdf() API](https://playwright.dev/python/docs/api/class-page)
|
||||
- [PDF Generation from HTML Comparison (2026)](https://medium.com/@coders.stop/pdf-generation-from-html-i-tested-puppeteer-playwright-and-wkhtmltopdf-so-you-dont-have-to-d14228d28c4c)
|
||||
- [Checkly: Generating PDFs with Playwright](https://www.checklyhq.com/docs/learn/playwright/generating-pdfs/)
|
||||
- [Print CSS Cheatsheet](https://www.customjs.space/blog/print-css-cheatsheet/)
|
||||
|
||||
### Pure CSS Diagrams
|
||||
- [Adrian Roselli: A CSS Venn Diagram](https://adrianroselli.com/2018/12/a-css-venn-diagram.html)
|
||||
- [CSS-Tricks: A CSS Venn Diagram](https://css-tricks.com/a-css-venn-diagram/)
|
||||
- [FreeFrontend: 17 Pure CSS Flowcharts](https://freefrontend.com/css-flowcharts/)
|
||||
- [Cory Rylan: Flow Charts with CSS Anchor Positioning](https://coryrylan.com/blog/flow-charts-with-css-anchor-positioning)
|
||||
- [Treeflex: CSS Tree Library](https://dumptyd.github.io/treeflex/)
|
||||
- [MDN: CSS Anchor Positioning](https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Anchor_positioning)
|
||||
|
||||
### Korean Typography
|
||||
- [W3C KLREQ: Requirements for Hangul Text Layout](https://www.w3.org/TR/klreq/)
|
||||
- [Pretendard GitHub](https://github.com/orioncactus/pretendard)
|
||||
- [Noto Sans KR on Google Fonts](https://fonts.google.com/noto/specimen/Noto+Sans+KR)
|
||||
- [CJK Typesetting in 2025](https://asianabsolute.co.uk/blog/cjk-typesetting-challenges-workflows-and-best-practices/)
|
||||
- [Typotheque: CJK Typesetting Principles](https://www.typotheque.com/articles/typesetting-cjk-text)
|
||||
- [CSS WG: word-break for Korean (Issue #4285)](https://github.com/w3c/csswg-drafts/issues/4285)
|
||||
|
||||
### FastAPI & SSE
|
||||
- [FastAPI SSE Tutorial](https://fastapi.tiangolo.com/tutorial/server-sent-events/)
|
||||
- [sse-starlette on PyPI](https://pypi.org/project/sse-starlette/)
|
||||
- [Real Python: FastAPI with Jinja2](https://realpython.com/fastapi-jinja2-template/)
|
||||
|
||||
### SVG vs CSS
|
||||
- [Adobe Blog: CSS vs SVG](https://blog.adobe.com/en/publish/2015/09/16/css-vs-svg-the-final-roundup)
|
||||
- [Sara Soueidan: Accessible Data Charts](https://www.sarasoueidan.com/blog/accessible-data-charts-for-khan-academy-2018-annual-report/)
|
||||
31
pyproject.toml
Normal file
31
pyproject.toml
Normal file
@@ -0,0 +1,31 @@
|
||||
[project]
|
||||
name = "design-agent"
|
||||
version = "0.1.0"
|
||||
description = "콘텐츠 시각 구조화 슬라이드 생성 에이전트"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
dependencies = [
|
||||
"fastapi>=0.115",
|
||||
"uvicorn>=0.30",
|
||||
"jinja2>=3.1",
|
||||
"pydantic>=2.0",
|
||||
"pydantic-settings>=2.0",
|
||||
"anthropic>=0.40",
|
||||
"httpx>=0.27",
|
||||
"python-multipart>=0.0.9",
|
||||
"sse-starlette>=2.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.0",
|
||||
"pytest-asyncio>=0.24",
|
||||
"ruff>=0.8",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py310"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
0
src/__init__.py
Normal file
0
src/__init__.py
Normal file
20
src/config.py
Normal file
20
src/config.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Design Agent 설정."""
|
||||
|
||||
anthropic_api_key: str = ""
|
||||
kei_api_url: str = "http://localhost:8000"
|
||||
log_level: str = "DEBUG"
|
||||
|
||||
# 슬라이드 크기
|
||||
slide_width: int = 1280
|
||||
slide_height: int = 720
|
||||
|
||||
model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}
|
||||
|
||||
|
||||
settings = Settings()
|
||||
161
src/content_editor.py
Normal file
161
src/content_editor.py
Normal 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
|
||||
172
src/design_director.py
Normal file
172
src/design_director.py
Normal 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
|
||||
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": "기본 결론",
|
||||
},
|
||||
],
|
||||
}
|
||||
60
src/main.py
Normal file
60
src/main.py
Normal file
@@ -0,0 +1,60 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pathlib import Path
|
||||
from pydantic import BaseModel
|
||||
from sse_starlette.sse import EventSourceResponse
|
||||
|
||||
from src.config import settings
|
||||
from src.pipeline import generate_slide
|
||||
|
||||
logging.basicConfig(level=getattr(logging, settings.log_level, logging.DEBUG))
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
app = FastAPI(title="Design Agent", version="0.1.0")
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["http://localhost:5174", "http://localhost:5173"],
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# 정적 파일 서빙 (CSS, 폰트 등)
|
||||
static_dir = Path(__file__).parent.parent / "static"
|
||||
if static_dir.exists():
|
||||
app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
|
||||
|
||||
|
||||
class SlideRequest(BaseModel):
|
||||
content: str
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
async def health():
|
||||
return {"status": "ok", "service": "design-agent"}
|
||||
|
||||
|
||||
@app.post("/api/generate")
|
||||
async def generate(req: SlideRequest):
|
||||
"""콘텐츠 → 슬라이드 생성 (SSE 스트리밍)."""
|
||||
async def event_stream():
|
||||
async for event in generate_slide(req.content):
|
||||
yield {
|
||||
"event": event["event"],
|
||||
"data": json.dumps(event["data"], ensure_ascii=False),
|
||||
}
|
||||
|
||||
return EventSourceResponse(event_stream())
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def frontend():
|
||||
"""프론트엔드 UI — static/index.html 서빙."""
|
||||
return FileResponse(str(static_dir / "index.html"), media_type="text/html")
|
||||
71
src/pipeline.py
Normal file
71
src/pipeline.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""DA-14: 전체 파이프라인 (3단계).
|
||||
|
||||
콘텐츠 입력 → Opus 분류 → 디자인 팀장 컨셉 → 텍스트 편집자 정리 → 렌더러 조립 → HTML 출력.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, AsyncIterator
|
||||
|
||||
from src.kei_client import classify_content, manual_classify
|
||||
from src.design_director import create_layout_concept, _fallback_single_page
|
||||
from src.content_editor import fill_content
|
||||
from src.renderer import render_slide
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def generate_slide(
|
||||
content: str,
|
||||
manual_layout: dict[str, Any] | None = None,
|
||||
) -> AsyncIterator[dict[str, str]]:
|
||||
"""콘텐츠를 슬라이드 HTML로 변환하는 전체 파이프라인.
|
||||
|
||||
Args:
|
||||
content: 원본 텍스트 콘텐츠
|
||||
manual_layout: 수동 레이아웃 명세 (Opus 대신 사용)
|
||||
|
||||
Yields:
|
||||
SSE 이벤트:
|
||||
{"event": "progress", "data": "단계 설명"}
|
||||
{"event": "result", "data": "완성 HTML"}
|
||||
{"event": "error", "data": "에러 메시지"}
|
||||
"""
|
||||
try:
|
||||
# 1단계: Kei 실장 (Opus) — 콘텐츠 분류
|
||||
yield {"event": "progress", "data": "1/4 Kei 실장이 콘텐츠를 분석 중..."}
|
||||
|
||||
if manual_layout:
|
||||
classification = manual_layout
|
||||
else:
|
||||
classification = await classify_content(content)
|
||||
if classification is None:
|
||||
classification = manual_classify(content)
|
||||
|
||||
logger.info(f"분류 완료: {len(classification.get('blocks', []))}개 블록")
|
||||
|
||||
# 2단계: 디자인 팀장 — 레이아웃 컨셉
|
||||
yield {"event": "progress", "data": "2/4 디자인 팀장이 레이아웃을 설계 중..."}
|
||||
|
||||
layout_concept = await create_layout_concept(content, classification)
|
||||
|
||||
total_pages = len(layout_concept.get("pages", []))
|
||||
total_blocks = sum(len(p.get("blocks", [])) for p in layout_concept.get("pages", []))
|
||||
logger.info(f"레이아웃 컨셉: {total_pages}페이지, {total_blocks}개 블록")
|
||||
|
||||
# 3단계: 텍스트 편집자 (Kei 역할) — 슬롯 텍스트 정리
|
||||
yield {"event": "progress", "data": "3/4 텍스트 편집자가 핵심을 정리 중..."}
|
||||
|
||||
layout_concept = await fill_content(content, layout_concept)
|
||||
|
||||
# 4단계: 실무자 — HTML 렌더링
|
||||
yield {"event": "progress", "data": "4/4 슬라이드를 조립 중..."}
|
||||
|
||||
html = render_slide(layout_concept)
|
||||
|
||||
yield {"event": "result", "data": html}
|
||||
logger.info(f"슬라이드 생성 완료: {total_pages}페이지")
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"파이프라인 오류: {e}")
|
||||
yield {"event": "error", "data": str(e)}
|
||||
171
src/renderer.py
Normal file
171
src/renderer.py
Normal file
@@ -0,0 +1,171 @@
|
||||
"""DA-11: 슬라이드 조합 렌더러.
|
||||
|
||||
블록 배치 명세(JSON)를 받아 Jinja2로 HTML을 생성한다.
|
||||
다중 페이지 지원: pages 배열의 각 페이지를 .slide div로 렌더링.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TEMPLATES_DIR = Path(__file__).parent.parent / "templates"
|
||||
STATIC_DIR = Path(__file__).parent.parent / "static"
|
||||
|
||||
|
||||
def create_jinja_env() -> Environment:
|
||||
"""Jinja2 환경 생성. templates/ 폴더를 로더로 사용."""
|
||||
return Environment(
|
||||
loader=FileSystemLoader(str(TEMPLATES_DIR)),
|
||||
autoescape=False,
|
||||
)
|
||||
|
||||
|
||||
def render_multi_page(layout_concept: dict[str, Any]) -> str:
|
||||
"""다중 페이지 레이아웃 컨셉으로 완성 HTML을 생성한다.
|
||||
|
||||
Args:
|
||||
layout_concept: 디자인 팀장 + 텍스트 편집자가 완성한 구조:
|
||||
{
|
||||
"title": "슬라이드 제목",
|
||||
"pages": [
|
||||
{
|
||||
"grid_areas": "...",
|
||||
"grid_columns": "...",
|
||||
"grid_rows": "...",
|
||||
"blocks": [{"area": "...", "type": "...", "data": {...}}]
|
||||
},
|
||||
...
|
||||
]
|
||||
}
|
||||
|
||||
Returns:
|
||||
완성된 HTML 문자열 (다중 페이지 시 .slide div 여러 개).
|
||||
"""
|
||||
env = create_jinja_env()
|
||||
title = layout_concept.get("title", "슬라이드")
|
||||
pages = layout_concept.get("pages", [])
|
||||
|
||||
if not pages:
|
||||
logger.warning("페이지가 없습니다. 빈 HTML 반환.")
|
||||
return "<html><body><p>페이지가 없습니다.</p></body></html>"
|
||||
|
||||
# 각 페이지의 블록을 개별 렌더링
|
||||
pages_rendered = []
|
||||
for page_idx, page in enumerate(pages):
|
||||
blocks_rendered = []
|
||||
for block in page.get("blocks", []):
|
||||
block_type = block.get("type", "")
|
||||
block_data = block.get("data", {})
|
||||
template_path = f"blocks/{block_type}.html"
|
||||
|
||||
try:
|
||||
block_template = env.get_template(template_path)
|
||||
rendered_html = block_template.render(**block_data)
|
||||
except Exception as e:
|
||||
logger.warning(f"블록 렌더링 실패 ({block_type}): {e}")
|
||||
rendered_html = f'<div class="body-text">블록 렌더링 실패: {block_type}</div>'
|
||||
|
||||
blocks_rendered.append({
|
||||
"area": block.get("area", "main"),
|
||||
"html": rendered_html,
|
||||
})
|
||||
|
||||
pages_rendered.append({
|
||||
"grid_areas": page.get("grid_areas", "'main'"),
|
||||
"grid_columns": page.get("grid_columns", "1fr"),
|
||||
"grid_rows": page.get("grid_rows", "auto"),
|
||||
"blocks": blocks_rendered,
|
||||
"page_number": page_idx + 1,
|
||||
})
|
||||
|
||||
# base 템플릿 렌더링
|
||||
base_template = env.get_template("slide-base.html")
|
||||
html = base_template.render(
|
||||
slide_title=title,
|
||||
pages=pages_rendered,
|
||||
total_pages=len(pages_rendered),
|
||||
)
|
||||
|
||||
# CSS를 인라인으로 삽입
|
||||
tokens_css = (STATIC_DIR / "tokens.css").read_text(encoding="utf-8")
|
||||
base_css = (STATIC_DIR / "base.css").read_text(encoding="utf-8")
|
||||
base_css = base_css.replace("@import url('./tokens.css');", "")
|
||||
|
||||
inline_css = f"<style>\n{tokens_css}\n{base_css}\n</style>"
|
||||
html = html.replace(
|
||||
'<link rel="stylesheet" href="/static/base.css">',
|
||||
inline_css,
|
||||
)
|
||||
|
||||
logger.info(f"슬라이드 렌더링 완료: {title}, {len(pages_rendered)}페이지")
|
||||
return html
|
||||
|
||||
|
||||
# 하위 호환: 기존 render_slide도 유지
|
||||
def render_slide(layout: dict[str, Any]) -> str:
|
||||
"""기존 단일 페이지 렌더링 (하위 호환).
|
||||
|
||||
pages 구조가 있으면 render_multi_page로 위임.
|
||||
없으면 기존 방식으로 단일 페이지 렌더링.
|
||||
"""
|
||||
if "pages" in layout:
|
||||
return render_multi_page(layout)
|
||||
|
||||
# 기존 단일 페이지 로직
|
||||
env = create_jinja_env()
|
||||
|
||||
blocks_rendered = []
|
||||
for block in layout.get("blocks", []):
|
||||
block_type = block["type"]
|
||||
block_data = block.get("data", {})
|
||||
template_path = f"blocks/{block_type}.html"
|
||||
|
||||
try:
|
||||
block_template = env.get_template(template_path)
|
||||
rendered_html = block_template.render(**block_data)
|
||||
except Exception as e:
|
||||
logger.warning(f"블록 렌더링 실패 ({block_type}): {e}")
|
||||
rendered_html = f'<div class="body-text">블록 렌더링 실패: {block_type}</div>'
|
||||
|
||||
blocks_rendered.append({
|
||||
"area": block["area"],
|
||||
"html": rendered_html,
|
||||
})
|
||||
|
||||
base_template = env.get_template("slide-base.html")
|
||||
html = base_template.render(
|
||||
slide_title=layout.get("title", ""),
|
||||
pages=[{
|
||||
"grid_areas": layout.get("grid_areas", "'header' 'main' 'footer'"),
|
||||
"grid_columns": layout.get("grid_columns", "1fr"),
|
||||
"grid_rows": layout.get("grid_rows", "auto 1fr auto"),
|
||||
"blocks": blocks_rendered,
|
||||
"page_number": 1,
|
||||
}],
|
||||
total_pages=1,
|
||||
)
|
||||
|
||||
tokens_css = (STATIC_DIR / "tokens.css").read_text(encoding="utf-8")
|
||||
base_css = (STATIC_DIR / "base.css").read_text(encoding="utf-8")
|
||||
base_css = base_css.replace("@import url('./tokens.css');", "")
|
||||
|
||||
inline_css = f"<style>\n{tokens_css}\n{base_css}\n</style>"
|
||||
html = html.replace(
|
||||
'<link rel="stylesheet" href="/static/base.css">',
|
||||
inline_css,
|
||||
)
|
||||
|
||||
logger.info(f"슬라이드 렌더링 완료: {layout.get('title', 'untitled')}")
|
||||
return html
|
||||
|
||||
|
||||
def render_standalone_block(block_type: str, data: dict[str, Any]) -> str:
|
||||
"""단일 블록을 독립 HTML로 렌더링 (테스트/미리보기용)."""
|
||||
env = create_jinja_env()
|
||||
template = env.get_template(f"blocks/{block_type}.html")
|
||||
return template.render(**data)
|
||||
69
static/base.css
Normal file
69
static/base.css
Normal file
@@ -0,0 +1,69 @@
|
||||
/* Design Agent — 기본 슬라이드 스타일 */
|
||||
@import url('./tokens.css');
|
||||
@import url('https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css');
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* 슬라이드 컨테이너: 16:9 고정 비율 */
|
||||
.slide {
|
||||
width: 1280px;
|
||||
height: 720px;
|
||||
aspect-ratio: 16 / 9;
|
||||
overflow: hidden;
|
||||
background: var(--color-bg);
|
||||
font-family: 'Pretendard Variable', 'Pretendard', 'Noto Sans KR', sans-serif;
|
||||
color: var(--color-text);
|
||||
font-size: var(--font-body);
|
||||
line-height: var(--line-height-ko);
|
||||
word-break: keep-all;
|
||||
padding: var(--spacing-page);
|
||||
display: grid;
|
||||
gap: var(--spacing-block);
|
||||
}
|
||||
|
||||
/* 슬라이드 제목 */
|
||||
.slide-title {
|
||||
font-size: var(--font-title);
|
||||
font-weight: var(--weight-black);
|
||||
color: var(--color-primary);
|
||||
border-bottom: var(--accent-border) solid var(--color-accent);
|
||||
padding-bottom: var(--spacing-small);
|
||||
}
|
||||
|
||||
/* 섹션 제목 */
|
||||
.section-title {
|
||||
font-size: var(--font-subtitle);
|
||||
font-weight: var(--weight-bold);
|
||||
color: var(--color-primary);
|
||||
margin-bottom: var(--spacing-small);
|
||||
}
|
||||
|
||||
/* 본문 */
|
||||
.body-text {
|
||||
font-size: var(--font-body);
|
||||
color: var(--color-text);
|
||||
line-height: var(--line-height-ko);
|
||||
}
|
||||
|
||||
/* 캡션/출처 */
|
||||
.caption {
|
||||
font-size: var(--font-caption);
|
||||
color: var(--color-text-light);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* 강조 텍스트 */
|
||||
.highlight {
|
||||
color: var(--color-accent);
|
||||
font-weight: var(--weight-bold);
|
||||
}
|
||||
|
||||
/* 경고/문제 강조 */
|
||||
.danger {
|
||||
color: var(--color-danger);
|
||||
font-weight: var(--weight-bold);
|
||||
}
|
||||
253
static/index.html
Normal file
253
static/index.html
Normal file
@@ -0,0 +1,253 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Design Agent — 슬라이드 생성기</title>
|
||||
<link rel="preconnect" href="https://cdn.jsdelivr.net">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: 'Pretendard Variable', sans-serif;
|
||||
background: #f1f5f9;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 24px;
|
||||
color: #1e293b;
|
||||
}
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.container {
|
||||
width: 100%;
|
||||
max-width: 1400px;
|
||||
display: grid;
|
||||
grid-template-columns: 400px 1fr;
|
||||
gap: 24px;
|
||||
flex: 1;
|
||||
}
|
||||
.input-panel {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.input-panel label {
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
textarea {
|
||||
width: 100%;
|
||||
height: 400px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
font-family: inherit;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.6;
|
||||
resize: vertical;
|
||||
word-break: keep-all;
|
||||
}
|
||||
textarea:focus {
|
||||
outline: none;
|
||||
border-color: #2563eb;
|
||||
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.1);
|
||||
}
|
||||
button {
|
||||
padding: 12px 24px;
|
||||
background: #2563eb;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
button:hover { background: #1d4ed8; }
|
||||
button:disabled { background: #94a3b8; cursor: not-allowed; }
|
||||
.btn-download {
|
||||
background: #16a34a;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.btn-download:hover { background: #15803d; }
|
||||
.progress {
|
||||
font-size: 0.85rem;
|
||||
color: #64748b;
|
||||
padding: 8px 0;
|
||||
}
|
||||
.preview-panel {
|
||||
background: #e2e8f0;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
overflow: auto;
|
||||
}
|
||||
.preview-label {
|
||||
color: #64748b;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.iframe-wrapper {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
||||
background: white;
|
||||
}
|
||||
iframe {
|
||||
width: 1280px;
|
||||
height: 720px;
|
||||
border: none;
|
||||
background: white;
|
||||
transform-origin: top left;
|
||||
/* scale은 JS에서 컨테이너 너비에 맞게 동적 계산 */
|
||||
}
|
||||
.error {
|
||||
color: #dc2626;
|
||||
font-size: 0.85rem;
|
||||
padding: 8px 12px;
|
||||
background: #fef2f2;
|
||||
border-radius: 6px;
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Design Agent — 슬라이드 생성기</h1>
|
||||
<div class="container">
|
||||
<div class="input-panel">
|
||||
<label>콘텐츠 입력</label>
|
||||
<textarea id="content" placeholder="슬라이드로 변환할 텍스트를 붙여넣으세요..."></textarea>
|
||||
<button id="btn-generate" onclick="generate()">슬라이드 생성</button>
|
||||
<div id="progress" class="progress"></div>
|
||||
<div id="error" class="error"></div>
|
||||
<button id="btn-download" class="btn-download" style="display:none" onclick="download()">HTML 다운로드</button>
|
||||
</div>
|
||||
<div class="preview-panel">
|
||||
<div class="preview-label">미리보기</div>
|
||||
<div class="iframe-wrapper" id="iframe-wrapper">
|
||||
<iframe id="preview"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
let generatedHTML = '';
|
||||
|
||||
function scalePreview() {
|
||||
const wrapper = document.getElementById('iframe-wrapper');
|
||||
const iframe = document.getElementById('preview');
|
||||
if (!wrapper || !iframe) return;
|
||||
const wrapperWidth = wrapper.clientWidth;
|
||||
const scale = wrapperWidth / 1280;
|
||||
iframe.style.transform = 'scale(' + scale + ')';
|
||||
wrapper.style.height = (720 * scale) + 'px';
|
||||
}
|
||||
|
||||
window.addEventListener('resize', scalePreview);
|
||||
window.addEventListener('load', scalePreview);
|
||||
|
||||
async function generate() {
|
||||
const content = document.getElementById('content').value.trim();
|
||||
if (!content) return;
|
||||
|
||||
const btn = document.getElementById('btn-generate');
|
||||
const progress = document.getElementById('progress');
|
||||
const error = document.getElementById('error');
|
||||
const downloadBtn = document.getElementById('btn-download');
|
||||
|
||||
btn.disabled = true;
|
||||
error.style.display = 'none';
|
||||
downloadBtn.style.display = 'none';
|
||||
progress.textContent = '시작 중...';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/generate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content }),
|
||||
});
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
// SSE 이벤트는 빈 줄(\n\n)로 구분
|
||||
const parts = buffer.split(/\r?\n\r?\n/);
|
||||
buffer = parts.pop() || '';
|
||||
|
||||
for (const part of parts) {
|
||||
if (!part.trim()) continue;
|
||||
|
||||
// 각 이벤트에서 event: 와 data: 추출
|
||||
let eventType = '';
|
||||
let eventData = '';
|
||||
|
||||
for (const line of part.split(/\r?\n/)) {
|
||||
if (line.startsWith('event:')) {
|
||||
eventType = line.slice(6).trim();
|
||||
} else if (line.startsWith('data:')) {
|
||||
eventData = line.slice(5).trim();
|
||||
}
|
||||
}
|
||||
|
||||
if (!eventData) continue;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(eventData);
|
||||
|
||||
if (eventType === 'progress') {
|
||||
progress.textContent = parsed;
|
||||
} else if (eventType === 'result') {
|
||||
generatedHTML = parsed;
|
||||
document.getElementById('preview').srcdoc = generatedHTML;
|
||||
downloadBtn.style.display = 'block';
|
||||
progress.textContent = '완료!';
|
||||
setTimeout(scalePreview, 100);
|
||||
} else if (eventType === 'error') {
|
||||
error.textContent = parsed;
|
||||
error.style.display = 'block';
|
||||
}
|
||||
} catch (e) {
|
||||
// ping 등 무시
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
error.textContent = '오류: ' + e.message;
|
||||
error.style.display = 'block';
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function download() {
|
||||
if (!generatedHTML) return;
|
||||
const blob = new Blob(['\uFEFF' + generatedHTML], { type: 'text/html;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'slide-' + Date.now() + '.html';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
42
static/tokens.css
Normal file
42
static/tokens.css
Normal file
@@ -0,0 +1,42 @@
|
||||
/* Design Agent — 디자인 토큰 */
|
||||
/* CLAUDE.md에 정의된 디자인 원칙을 CSS 변수로 구현 */
|
||||
|
||||
:root {
|
||||
/* 색상 */
|
||||
--color-primary: #1e293b;
|
||||
--color-accent: #2563eb;
|
||||
--color-neutral: #64748b;
|
||||
--color-bg: #ffffff;
|
||||
--color-bg-subtle: #f8fafc;
|
||||
--color-border: #e2e8f0;
|
||||
--color-danger: #dc2626;
|
||||
--color-success: #16a34a;
|
||||
--color-text: #1e293b;
|
||||
--color-text-secondary: #64748b;
|
||||
--color-text-light: #94a3b8;
|
||||
|
||||
/* 폰트 크기 */
|
||||
--font-title: 2rem;
|
||||
--font-subtitle: 1.25rem;
|
||||
--font-body: 0.95rem;
|
||||
--font-caption: 0.8rem;
|
||||
--font-small: 0.7rem;
|
||||
|
||||
/* 폰트 두께 */
|
||||
--weight-normal: 400;
|
||||
--weight-medium: 500;
|
||||
--weight-bold: 700;
|
||||
--weight-black: 900;
|
||||
|
||||
/* 여백 */
|
||||
--spacing-page: 40px;
|
||||
--spacing-block: 20px;
|
||||
--spacing-inner: 16px;
|
||||
--spacing-small: 8px;
|
||||
|
||||
/* 기타 */
|
||||
--radius: 6px;
|
||||
--border-width: 1px;
|
||||
--accent-border: 3px;
|
||||
--line-height-ko: 1.7;
|
||||
}
|
||||
65
templates/blocks/card-grid.html
Normal file
65
templates/blocks/card-grid.html
Normal file
@@ -0,0 +1,65 @@
|
||||
<!-- 카드 그리드 블록: 2~4열 카드 배열 -->
|
||||
<div class="block-card-grid" style="--card-count: {{ cards|length }}">
|
||||
{% for card in cards %}
|
||||
<div class="card" style="border-top-color: {{ card.color | default('var(--color-accent)') }}">
|
||||
{% if card.icon %}<div class="card-icon">{{ card.icon }}</div>{% endif %}
|
||||
<div class="card-title">{{ card.title }}</div>
|
||||
{% if card.category %}<span class="card-category">{{ card.category }}</span>{% endif %}
|
||||
<div class="card-description">{{ card.description }}</div>
|
||||
{% if card.source %}<div class="card-source">{{ card.source }}</div>{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.block-card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--card-count, 3), 1fr);
|
||||
gap: var(--spacing-inner);
|
||||
height: 100%;
|
||||
}
|
||||
.card {
|
||||
background: var(--color-bg);
|
||||
border: var(--border-width) solid var(--color-border);
|
||||
border-top: var(--accent-border) solid var(--color-accent);
|
||||
border-radius: var(--radius);
|
||||
padding: var(--spacing-inner);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.card-icon {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: var(--spacing-small);
|
||||
}
|
||||
.card-title {
|
||||
font-size: var(--font-subtitle);
|
||||
font-weight: var(--weight-bold);
|
||||
color: var(--color-primary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.card-category {
|
||||
font-size: var(--font-small);
|
||||
font-weight: var(--weight-medium);
|
||||
color: var(--color-accent);
|
||||
background: #dbeafe;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
display: inline-block;
|
||||
margin-bottom: var(--spacing-small);
|
||||
width: fit-content;
|
||||
}
|
||||
.card-description {
|
||||
font-size: var(--font-body);
|
||||
color: var(--color-text);
|
||||
line-height: var(--line-height-ko);
|
||||
flex: 1;
|
||||
}
|
||||
.card-source {
|
||||
font-size: var(--font-small);
|
||||
color: var(--color-text-light);
|
||||
font-style: italic;
|
||||
margin-top: var(--spacing-small);
|
||||
border-top: var(--border-width) solid var(--color-border);
|
||||
padding-top: var(--spacing-small);
|
||||
}
|
||||
</style>
|
||||
58
templates/blocks/comparison-table.html
Normal file
58
templates/blocks/comparison-table.html
Normal file
@@ -0,0 +1,58 @@
|
||||
<!-- 비교 테이블 블록: 다항목 비교 -->
|
||||
<div class="block-table">
|
||||
<table class="comparison-table">
|
||||
<thead>
|
||||
<tr>
|
||||
{% for header in headers %}
|
||||
<th{% if loop.first %} class="table-row-header"{% endif %}>{{ header }}</th>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in rows %}
|
||||
<tr>
|
||||
{% for cell in row %}
|
||||
<td{% if loop.first %} class="table-row-header"{% endif %}>{{ cell }}</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.block-table {
|
||||
overflow: auto;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.comparison-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--font-caption);
|
||||
line-height: var(--line-height-ko);
|
||||
}
|
||||
.comparison-table th {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
font-weight: var(--weight-bold);
|
||||
padding: var(--spacing-small) var(--spacing-inner);
|
||||
text-align: left;
|
||||
font-size: var(--font-caption);
|
||||
}
|
||||
.comparison-table td {
|
||||
padding: var(--spacing-small) var(--spacing-inner);
|
||||
border-bottom: var(--border-width) solid var(--color-border);
|
||||
font-size: var(--font-caption);
|
||||
vertical-align: top;
|
||||
}
|
||||
.comparison-table tbody tr:nth-child(even) {
|
||||
background: var(--color-bg-subtle);
|
||||
}
|
||||
.table-row-header {
|
||||
font-weight: var(--weight-bold);
|
||||
color: var(--color-primary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
51
templates/blocks/comparison.html
Normal file
51
templates/blocks/comparison.html
Normal file
@@ -0,0 +1,51 @@
|
||||
<!-- 비교 블록: 2단 병렬 레이아웃 -->
|
||||
<div class="block-comparison">
|
||||
<div class="comparison-left">
|
||||
<div class="comparison-header comparison-header--left">{{ left_title }}</div>
|
||||
{% if left_subtitle %}<div class="comparison-subtitle">{{ left_subtitle }}</div>{% endif %}
|
||||
<div class="comparison-content">{{ left_content }}</div>
|
||||
</div>
|
||||
<div class="comparison-divider"></div>
|
||||
<div class="comparison-right">
|
||||
<div class="comparison-header comparison-header--right">{{ right_title }}</div>
|
||||
{% if right_subtitle %}<div class="comparison-subtitle">{{ right_subtitle }}</div>{% endif %}
|
||||
<div class="comparison-content">{{ right_content }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.block-comparison {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
gap: var(--spacing-inner);
|
||||
height: 100%;
|
||||
}
|
||||
.comparison-divider {
|
||||
width: 1px;
|
||||
background: var(--color-border);
|
||||
}
|
||||
.comparison-header {
|
||||
font-size: var(--font-subtitle);
|
||||
font-weight: var(--weight-bold);
|
||||
padding-bottom: var(--spacing-small);
|
||||
margin-bottom: var(--spacing-small);
|
||||
border-bottom: var(--accent-border) solid;
|
||||
}
|
||||
.comparison-header--left {
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
.comparison-header--right {
|
||||
border-color: var(--color-danger);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
.comparison-subtitle {
|
||||
font-size: var(--font-caption);
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: var(--spacing-small);
|
||||
}
|
||||
.comparison-content {
|
||||
font-size: var(--font-body);
|
||||
line-height: var(--line-height-ko);
|
||||
}
|
||||
</style>
|
||||
31
templates/blocks/conclusion-bar.html
Normal file
31
templates/blocks/conclusion-bar.html
Normal file
@@ -0,0 +1,31 @@
|
||||
<!-- 결론 바 블록: 하단 핵심 한 줄 -->
|
||||
<div class="block-conclusion">
|
||||
<div class="conclusion-label">{{ label | default('핵심 요약') }}</div>
|
||||
<div class="conclusion-text">{{ conclusion_text }}</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.block-conclusion {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
padding: var(--spacing-inner) var(--spacing-block);
|
||||
border-radius: var(--radius);
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.conclusion-label {
|
||||
font-size: var(--font-caption);
|
||||
color: var(--color-text-light);
|
||||
font-weight: var(--weight-medium);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.conclusion-text {
|
||||
font-size: var(--font-subtitle);
|
||||
font-weight: var(--weight-bold);
|
||||
line-height: var(--line-height-ko);
|
||||
}
|
||||
</style>
|
||||
61
templates/blocks/process.html
Normal file
61
templates/blocks/process.html
Normal file
@@ -0,0 +1,61 @@
|
||||
<!-- 프로세스 블록: 가로 단계 흐름 -->
|
||||
<div class="block-process">
|
||||
{% for step in steps %}
|
||||
<div class="process-step">
|
||||
<div class="process-number">{{ step.number | default(loop.index) }}</div>
|
||||
<div class="process-title">{{ step.title }}</div>
|
||||
{% if step.description %}<div class="process-desc">{{ step.description }}</div>{% endif %}
|
||||
</div>
|
||||
{% if not loop.last %}
|
||||
<div class="process-arrow">→</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.block-process {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-small);
|
||||
height: 100%;
|
||||
}
|
||||
.process-step {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: var(--spacing-inner);
|
||||
background: var(--color-bg-subtle);
|
||||
border: var(--border-width) solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.process-number {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-accent);
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: var(--weight-bold);
|
||||
font-size: var(--font-body);
|
||||
margin: 0 auto var(--spacing-small);
|
||||
}
|
||||
.process-title {
|
||||
font-size: var(--font-body);
|
||||
font-weight: var(--weight-bold);
|
||||
color: var(--color-primary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.process-desc {
|
||||
font-size: var(--font-caption);
|
||||
color: var(--color-text-secondary);
|
||||
line-height: var(--line-height-ko);
|
||||
}
|
||||
.process-arrow {
|
||||
font-size: 1.5rem;
|
||||
color: var(--color-accent);
|
||||
font-weight: var(--weight-bold);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
29
templates/blocks/quote-block.html
Normal file
29
templates/blocks/quote-block.html
Normal file
@@ -0,0 +1,29 @@
|
||||
<!-- 강조 인용 블록: 문제 제기, 핵심 메시지 -->
|
||||
<div class="block-quote">
|
||||
<div class="quote-text">{{ quote_text }}</div>
|
||||
{% if source %}<div class="quote-source">{{ source }}</div>{% endif %}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.block-quote {
|
||||
background: var(--color-bg-subtle);
|
||||
border-left: var(--accent-border) solid var(--color-danger);
|
||||
padding: var(--spacing-inner) var(--spacing-block);
|
||||
border-radius: 0 var(--radius) var(--radius) 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
.quote-text {
|
||||
font-size: var(--font-body);
|
||||
color: var(--color-text);
|
||||
line-height: var(--line-height-ko);
|
||||
font-weight: var(--weight-medium);
|
||||
}
|
||||
.quote-source {
|
||||
font-size: var(--font-caption);
|
||||
color: var(--color-text-light);
|
||||
font-style: italic;
|
||||
margin-top: var(--spacing-small);
|
||||
}
|
||||
</style>
|
||||
88
templates/blocks/relationship.html
Normal file
88
templates/blocks/relationship.html
Normal file
@@ -0,0 +1,88 @@
|
||||
<!-- 관계도 블록: 벤 다이어그램 -->
|
||||
<div class="block-relationship">
|
||||
<div class="venn-container">
|
||||
<div class="venn-outer">
|
||||
<span class="venn-outer-label">{{ center_label }}</span>
|
||||
<span class="venn-outer-sub">{{ center_sub }}</span>
|
||||
</div>
|
||||
{% for item in items %}
|
||||
<div class="venn-inner venn-inner--{{ loop.index }}" style="background: {{ item.color | default('rgba(37, 99, 235, 0.8)') }}">
|
||||
<span>{{ item.label }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if description %}
|
||||
<div class="relationship-desc">
|
||||
{{ description }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.block-relationship {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
gap: var(--spacing-inner);
|
||||
}
|
||||
.venn-container {
|
||||
position: relative;
|
||||
width: 280px;
|
||||
height: 280px;
|
||||
}
|
||||
.venn-outer {
|
||||
width: 280px;
|
||||
height: 280px;
|
||||
border-radius: 50%;
|
||||
border: 3px solid var(--color-accent);
|
||||
background: rgba(37, 99, 235, 0.05);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.venn-outer-label {
|
||||
font-size: var(--font-subtitle);
|
||||
font-weight: var(--weight-black);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
.venn-outer-sub {
|
||||
font-size: var(--font-caption);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.venn-inner {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: var(--font-caption);
|
||||
font-weight: var(--weight-bold);
|
||||
}
|
||||
.venn-inner--1 {
|
||||
width: 70px; height: 70px;
|
||||
top: 30px; left: 30px;
|
||||
background: rgba(16, 185, 129, 0.85);
|
||||
}
|
||||
.venn-inner--2 {
|
||||
width: 80px; height: 80px;
|
||||
bottom: 40px; left: 30px;
|
||||
background: rgba(59, 130, 246, 0.85);
|
||||
}
|
||||
.venn-inner--3 {
|
||||
width: 75px; height: 75px;
|
||||
top: 60px; right: 25px;
|
||||
background: rgba(139, 92, 246, 0.85);
|
||||
}
|
||||
.relationship-desc {
|
||||
font-size: var(--font-body);
|
||||
color: var(--color-text);
|
||||
text-align: center;
|
||||
max-width: 500px;
|
||||
line-height: var(--line-height-ko);
|
||||
}
|
||||
</style>
|
||||
52
templates/slide-base.html
Normal file
52
templates/slide-base.html
Normal file
@@ -0,0 +1,52 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>{{ slide_title | default('슬라이드') }}</title>
|
||||
<link rel="stylesheet" href="/static/base.css">
|
||||
<style>
|
||||
{% for page in pages %}
|
||||
.slide-{{ page.page_number }} {
|
||||
grid-template-areas: {{ page.grid_areas }};
|
||||
grid-template-columns: {{ page.grid_columns | default('1fr') }};
|
||||
grid-template-rows: {{ page.grid_rows | default('auto 1fr auto') }};
|
||||
}
|
||||
{% for block in page.blocks %}
|
||||
.slide-{{ page.page_number }} .area-{{ block.area }} {
|
||||
grid-area: {{ block.area }};
|
||||
}
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
|
||||
/* 다중 페이지: 페이지 간 간격 */
|
||||
.slide + .slide {
|
||||
margin-top: 40px;
|
||||
}
|
||||
|
||||
/* 인쇄 시 페이지 분리 */
|
||||
@media print {
|
||||
.slide {
|
||||
page-break-after: always;
|
||||
}
|
||||
.slide + .slide {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{% for page in pages %}
|
||||
<div class="slide slide-{{ page.page_number }}">
|
||||
{% if loop.first and slide_title %}
|
||||
<div class="slide-title" style="grid-area: header;">{{ slide_title }}</div>
|
||||
{% endif %}
|
||||
|
||||
{% for block in page.blocks %}
|
||||
<div class="area-{{ block.area }}">
|
||||
{{ block.html }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</body>
|
||||
</html>
|
||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
Reference in New Issue
Block a user