5단계 파이프라인 전면 재작성 + Figma 추출 계획 업데이트

DA-12: 1단계 Kei 실장 — 꼭지 2~5개 추출 + 레이어/강조/배치/이미지/표/자세히보기 판단
DA-13: 2단계 디자인 팀장 — catalog 연동 + 블록 매핑 + 공간 배분 + 글자 수 가이드
DA-13b: 3단계 텍스트 편집자 — 글자 수 가이드 참고, 의미 우선 편집 + 자세히보기(요약+상세)
DA-14: 4단계 실무자(AI+코드) + 5단계 팀장 재검토 (균형 점검 → 2차 조정)

문서:
- CLAUDE.md: 5단계 프로세스 + 이미지/표/자세히보기 처리 원칙
- PLAN.md: DA-12~14 태스크 전면 재작성
- PROGRESS.md: 동기화
- FIGMA-COMPONENT-EXTRACTION-PLAN.md: 모드 독립 블록, 변환 규칙, image-block/details-block, MCP, 토큰 매핑

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-25 08:44:10 +09:00
parent 9a780828df
commit 33bd3a56c6
9 changed files with 1015 additions and 397 deletions

15
.mcp.json Normal file
View File

@@ -0,0 +1,15 @@
{
"mcpServers": {
"Framelink Figma MCP": {
"command": "cmd",
"args": [
"/c",
"npx",
"-y",
"figma-developer-mcp",
"--figma-api-key=figd_R6ASvFG2IHcHs35_XFPJh0sTkvp4RxWyEhMhT9vv",
"--stdio"
]
}
}
}

395
CLAUDE.md
View File

@@ -2,70 +2,175 @@
## 프로젝트 목적 ## 프로젝트 목적
텍스트 콘텐츠를 **1페이지 가로 슬라이드**로 시각 구조화하는 독립 에이전트. 텍스트/MDX 콘텐츠를 **가로 슬라이드(1페이지 또는 다중 페이지)**로 시각 구조화하는 독립 에이전트.
콘텐츠의 의미를 분석하여 적합한 레이아웃 블록을 선택하고, 핵심만 추출하여 깔끔한 HTML/CSS로 렌더링한다. 콘텐츠의 의미를 분석하여 적합한 레이아웃 블록을 선택하고, 핵심만 추출하여 깔끔한 HTML/CSS로 렌더링한다.
**핵심 원칙:** 전체 페이지를 하나의 고정 템플릿으로 찍어내는 것이 아니라, 콘텐츠를 분석 → 각 덩어리별로 적합한 레이아웃 블록 선택 → 조합하여 배치. **핵심 원칙:**
- 전체 페이지를 하나의 고정 템플릿으로 찍어내는 것이 아니라, 콘텐츠를 분석 → 각 덩어리별로 적합한 레이아웃 블록 선택 → 조합하여 배치
- 기획자(편집자)가 정리한 텍스트가 기준. **디자인이 텍스트에 맞춘다** (텍스트가 디자인에 맞추는 것이 아님)
- **모든 판단은 실장/팀장/편집자의 사고. 하드코딩 없음**
--- ---
## 아키텍처 ## 아키텍처 (5단계 파이프라인)
``` ```
Kei (실장) — Kei Persona API 호출 [1단계] Kei 실장 (Sonnet) — AI 사고
"이 콘텐츠는 비교+정의+관계도 구조다. 이렇게 배치해라." 꼭지 추출 → 레이어 수준 → 강조 판단 → 배치 방향
디자인 팀장 (Sonnet) [2단계] 디자인 팀장 (Sonnet) — AI 사고
"비교는 2단, 정의는 카드 3열, 관계도는 벤. 핵심만 남기고 나머지 버려." 블록 매핑 + 공간 배분 + 글자 수 가이드 → 편집자에게 전달
실행자 (CSS Grid 렌더러) [3단계] Kei 텍스트 편집자 (Sonnet) — AI 사고
"팀장이 정한 대로 CSS Grid로 조립." 글자 수 가이드 참고하되 내용 의미 우선. 도메인 용어 보존하며 편집
[4단계] 디자인 실무자 (Sonnet + Jinja2 + CSS) — AI + 코드
편집자가 정리한 텍스트에 맞게 디자인 조정 + HTML 조립
[5단계] 디자인 팀장 (Sonnet) — AI 사고
전체 균형 재검토 → 공간 재배분 → 2차 조정 지시
``` ```
### 역할 분리 ### 역할 분리
| 역할 | 담당 | 하는 일 | 하지 않는 일 | | 역할 | 담당 | 방식 | 하는 일 | 하지 않는 일 |
|------|------|---------|------------| |------|------|------|---------|------------|
| Kei (실장) | Opus via Kei API | 콘텐츠 의미 분석, 유형 분류, 배치 방향 결정 | 디자인, CSS 작성 | | Kei 실장 | Sonnet | AI | 꼭지 추출, 레이어 판단, 강조 판단, 배치 방향, 이미지/표/상세 판단 | 디자인, 텍스트 편집 |
| 디자인 팀장 | Sonnet | 블록 타입 선택, 콘텐츠 선별(70% 버림), 슬롯 채우기, 세부 기준 수립 | 콘텐츠 의미 판단 | | 디자인 팀장 | Sonnet | AI | catalog에서 블록 선택, 공간 배분, 겹침 방지, 글자 수 가이드, 전체 재검토 | 텍스트 정리, 콘텐츠 의미 판단 |
| 실행자 | CSS Grid 렌더러 | 확정적 HTML/CSS 생성, 디자인 토큰 적용 | 판단, 선택 | | 텍스트 편집자 | Sonnet | AI | 도메인 용어 보존하며 편집, 출처 보존, 표 내용 편집 | 레이아웃 결정, 디자인 판단 |
| 디자인 실무자 | Sonnet + 코드 | AI + 코드 | 텍스트에 맞게 디자인 조정, HTML/CSS 조립, 이미지 크기 조정, 표 스케일링 | 콘텐츠 의미 판단 |
--- ---
## 핵심 프로세스 ## 핵심 프로세스
``` ```
사용자 콘텐츠 입력 (텍스트 붙여넣기 또는 파일 업로드) 사용자 콘텐츠 입력 (텍스트/MDX 붙여넣기 또는 파일 업로드)
[1단계] Kei 실장(Opus) — 콘텐츠 유형 [1단계] Kei 실장 — 꼭지 추출 +
→ "이건 비교(A vs B) + 정의(3개 용어) + 관계도(상위/하위)"
→ 적합한 블록 조합 결정 꼭지 추출:
- 본문에서 핵심 꼭지 추출 (2~5개)
- 1페이지 적정 꼭지 수: 5개
페이지 분리 판단:
- 중요도가 5개를 넘고 레이어도 동등하면 → 2페이지로 분리
- 내용과 의미 기반으로 자연스러운 분할 (2/3, 3/4, 4/3, 5/2 등)
- 5개인데 내용이 많으면 → 꼭지 레이어를 보고 세부 내용은 자세히보기로
각 꼭지 분석:
- 레이어 수준 (도입/핵심/보조/결론)
- 강조 판단 (어떤 꼭지를 눈에 띄게 할 것인가)
- 배치 방향 (세로로 긴 꼭지, 가로 나열 꼭지)
이미지 판단:
- 몇 개인지, 어떤 꼭지에 속하는지
- 핵심인지(도표/차트) 보조인지(참고 문서 표지)
- 텍스트가 포함된 이미지인지 (너무 축소하면 안 됨)
표 판단:
- 행/열 규모
- 전체 표시 가능한지, 요약 필요한지
상세 콘텐츠 판단:
- 너무 구체적/세부적인 내용은 "자세히보기" 대상
[2단계] 디자인 팀장(Sonnet) — 레이아웃 컨셉만 [2단계] 디자인 팀장 — 레이아웃 설계
→ "이 파트는 카드로, 이건 비교로, 2페이지 필요"
블록 배치 + 페이지 수 + 슬롯 목록 (텍스트는 채우지 않음) 블록 매핑:
- catalog 메뉴판에서 각 꼭지에 적합한 블록 선택
- 꼭지의 성격을 보고 판단 (출처 있으면 example-card, 정의면 card-grid 등)
이미지 배치:
- 원본 이미지 크기 확인 (Pillow Image.open().size)
- 가로/세로 비율에 따라 영역 결정
(가로형이면 전체 너비, 세로형이면 텍스트 옆)
- 텍스트 포함 도표는 너무 작게 하면 안 됨
- 이미지는 원본 그대로 사용, 크기만 조절
표 배치:
- 행×열 규모 보고 공간 안에 들어가는지 판단
- 안 들어가면 실장에게 요약 요청 또는 2페이지 분리
자세히보기 설계:
- 상세 콘텐츠는 <details>/<summary> 영역으로 설계
공간 배분:
- 전체 공간에서 영역별 비율 결정
- 꼭지끼리 겹치지 않도록 grid-template 설계
- 각 블록의 대략적 글자 수 가이드
페이지 판단:
- 안 들어가면 2페이지로 분리
[3단계] 텍스트 편집자(Sonnet, Kei 역할) — 슬롯 텍스트 정리 [3단계] Kei 텍스트 편집자 텍스트 정리
→ 도메인 전문가로서 원본 핵심을 유지하며 각 슬롯 분량에 맞게 편집
→ 과도한 요약 금지, 출처 보존, 개조식 작성 - 팀장의 글자 수 가이드 참고하되, 내용 의미가 우선
- 전체 컨텍스트와 핵심 용어 유지
- 도메인 전문가로서 세련된 표현으로 편집
- 출처 보존, 개조식 작성, 날조 금지
- 결과 글자 수가 가이드와 다를 수 있음 (의미 > 글자 수)
- 표 내용도 편집 (핵심 행/열 선택, 요약 등)
- 자세히보기 대상은 요약 버전 + 상세 버전 둘 다 작성
[4단계] 실행자(CSS Grid) — 확정적 HTML 생성 [4단계] 디자인 실무자 — 디자인 조정 + HTML 조립
→ 블록 타입에 맞는 CSS 템플릿 적용
→ 디자인 토큰 (색상, 여백, 폰트 크기) 적용 텍스트 맞춤:
→ 다중 페이지 시 page-break 처리 - 편집자가 정리한 텍스트 양에 맞게 디자인 조정
- 텍스트가 가이드보다 길면 → 폰트/여백/박스를 조정 (텍스트를 자르지 않음)
- 빈 공간 방치 안 함 (박스 줄이거나 공간 활용)
이미지 처리:
- object-fit: contain (비율 유지, 잘리지 않게)
- 팀장이 정한 영역 크기에 맞춤
표 처리:
- table-layout: fixed + container query 폰트 스케일링
- 행/열 수에 따라 셀 크기 자동 조정
자세히보기:
- <details>/<summary>로 접기/펼치기
- 인쇄 시 자동 펼침 (JavaScript)
HTML 조립:
- Jinja2 템플릿 렌더링
- 다중 페이지 시 page-break 처리
미리보기 → 사용자 확인 → HTML 다운로드 [5단계] 디자인 팀장 — 전체 재검토
균형 점검:
- 1차 조립 결과의 전체 균형 확인
- 블록별 채움 비율 (텍스트 양 vs 공간)
- 블록 간 균형 (한쪽만 빽빽하고 다른 쪽 비어있지 않은지)
이미지/표 점검:
- 이미지 크기가 적절한지 (너무 작아서 안 보이지 않는지)
- 표가 읽을 수 있는 크기인지
조정:
- 필요 시 공간 재배분 → 실무자에게 2차 조정 지시
- 좌우 불균형, 어색한 빈 공간 해소
- 최종 HTML 출력
미리보기 → HTML 다운로드
``` ```
**핵심 원칙:** 디자인 팀장은 레이아웃만 결정하고 콘텐츠를 건드리지 않는다. 텍스트 정리는 도메인 지식이 있는 Kei 역할(텍스트 편집자)이 한다. **핵심 원칙:**
``` - 디자인 팀장은 레이아웃 + 공간 배분. 텍스트를 건드리지 않는다.
- 텍스트 편집자가 정리한 텍스트가 기준. 디자인이 텍스트에 맞춘다.
- 실무자는 텍스트를 자르지 않고, 디자인을 조정한다.
- 이미지는 원본 그대로 사용, 크기만 조절.
- 표는 표로 유지, 공간 안 되면 요약하거나 페이지 분리.
- 상세 내용은 `<details>`로 접기/펼치기.
- 1차 조립 후 팀장이 전체 균형을 재검토하여 2차 조정.
- **모든 기준은 하드코딩 없이 실장/팀장/편집자의 사고로 판단.**
--- ---
## 콘텐츠 유형 분류 기준 ## 콘텐츠 유형 분류 기준
Opus가 콘텐츠를 분석하여 아래 유형으로 분류한다. 실장이 콘텐츠를 분석하여 아래 유형으로 분류한다.
**이 분류는 하드코딩이 아니라, Opus가 매번 사고하여 판단한다.** **이 분류는 하드코딩이 아니라, 실장이 매번 사고하여 판단한다.**
| 텍스트 패턴 | 유형 | 적합한 블록 | | 텍스트 패턴 | 유형 | 적합한 블록 |
|------------|------|-----------| |------------|------|-----------|
@@ -78,6 +183,9 @@ Opus가 콘텐츠를 분석하여 아래 유형으로 분류한다.
| 연도별 사건, 로드맵 | 시간 순서 | 타임라인 (가로/세로) | | 연도별 사건, 로드맵 | 시간 순서 | 타임라인 (가로/세로) |
| 핵심 메시지, 결론 | 강조 | 결론 바 / 인용 블록 | | 핵심 메시지, 결론 | 강조 | 결론 바 / 인용 블록 |
| 문제 상황, 경고 | 문제 제기 | 경고 박스 / 강조 인용 | | 문제 상황, 경고 | 문제 제기 | 경고 박스 / 강조 인용 |
| 이미지 + 텍스트 | 이미지 참조 | 전체너비 / 사이드 / 썸네일 |
| 다항목 데이터 | 표 | 비교 테이블 |
| 세부/구체적 내용 | 자세히보기 | `<details>/<summary>` |
--- ---
@@ -130,10 +238,39 @@ Opus가 콘텐츠를 분석하여 아래 유형으로 분류한다.
- 슬롯: 행/열 헤더, 셀 내용 - 슬롯: 행/열 헤더, 셀 내용
- 용도: 다차원 비교, 기능 매트릭스 - 용도: 다차원 비교, 기능 매트릭스
### 10. 이미지 참조 (image-ref) ### 10. 이미지 블록 (image-block)
- 이미지 썸네일 + 캡션 - 이미지 + 캡션
- 슬롯: 이미지 경로, 캡션 텍스트 - 3가지 변형: 전체너비(image-full), 텍스트옆(image-side), 썸네일(image-thumb)
- 용도: 근거 자료, 문서 참조, 사진 - 슬롯: 이미지 경로, 캡션 텍스트, 이미지 크기 정보
- 용도: 도표, 다이어그램, 근거 자료, 문서 참조
- CSS: object-fit: contain (비율 유지, 잘리지 않음)
---
## 이미지 처리 원칙
- 원본 이미지를 **그대로 사용** (crop/재구성은 극히 예외)
- 팀장이 슬라이드 구조에 맞게 **크기만 조절** (가로/세로 비율 유지)
- 이미지 크기 읽기: Pillow `Image.open().size` (헤더만 읽음, 전체 로드 안 함)
- 가로형(ratio > 1.2) → 전체 너비 배치
- 세로형(ratio < 0.8) → 텍스트 옆 배치
- 텍스트 포함 도표 → 너무 작게 축소하면 안 됨
- CSS: `object-fit: contain` (전체 보이게, 비율 유지)
## 표 처리 원칙
- 표는 **표로 유지** (다른 형태로 전환하지 않음)
- 공간에 안 들어가면: 요약하거나 페이지 분리
- CSS: `table-layout: fixed` + container query 폰트 스케일링
- 표 내용 편집은 Kei 텍스트 편집자가 담당
## 자세히보기 (상세 콘텐츠) 원칙
- HTML 네이티브 `<details>/<summary>` 사용 (JavaScript 불필요)
- 슬라이드 표면: 요약/핵심만 표시
- 펼치면: 전체 상세 내용 표시
- 인쇄 시: JavaScript 6줄로 자동 펼침
- 정보 밀도는 실장/팀장이 사고로 판단 (하드코딩 기준 없음)
--- ---
@@ -141,19 +278,25 @@ Opus가 콘텐츠를 분석하여 아래 유형으로 분류한다.
### 레이아웃 배치 규칙 ### 레이아웃 배치 규칙
- CSS Grid 기반 (`grid-template-areas`) - CSS Grid 기반 (`grid-template-areas`)
- 가로 슬라이드 비율: 16:9 (1280×720 또는 1920×1080) - 가로 슬라이드 비율: 16:9 (1280×720)
- 최대 블록 수: 1페이지에 4~6 - 1페이지 적정 꼭지 수: 5
- 정보 계층: 위 → 아래 (문제 제기 → 분석 → 결론) - 정보 계층: 위 → 아래 (문제 제기 → 분석 → 결론)
- 여백: 블록 간 최소 20px, 페이지 패딩 40px - 여백: 블록 간 최소 20px, 페이지 패딩 40px
### 페이지 분리 기준
- 꼭지 5개 이하 + 내용 적절 → 1페이지
- 꼭지 5개 + 내용 많음 → 1페이지 + 일부 자세히보기
- 꼭지 5개 초과 + 레이어 동등 → 2페이지 (의미 기반 분할)
- 분할 비율: 2/3, 3/4, 4/3, 5/2 등 내용에 따라
### 블록 조합 예시 ### 블록 조합 예시
``` ```
┌─────────────────────────────────────────────┐ ┌─────────────────────────────────────────────┐
│ [강조 인용] 문제 제기 │ │ [강조 인용] 문제 제기 │
├──────────────────┬──────────────────────────┤ ├──────────────────┬──────────────────────────┤
│ [비교] │ [카드 그리드] │ [사례 카드 2열] │ [카드 그리드 3열]
2단 비교 │ 정의 3열 정책 사례 │ 용어 정의
├──────────────────┴──────────────────────────┤ ├──────────────────┴──────────────────────────┤
│ [관계도] 벤 다이어그램 │ │ [관계도] 벤 다이어그램 │
├─────────────────────────────────────────────┤ ├─────────────────────────────────────────────┤
@@ -163,6 +306,29 @@ Opus가 콘텐츠를 분석하여 아래 유형으로 분류한다.
--- ---
## 글자 수 추정 (타이포그래피 기반)
한글은 글자 너비가 거의 일정하므로, 블록 크기에서 글자 수를 계산할 수 있다.
**이 계산은 팀장의 글자 수 가이드를 보조하는 참고 도구이지, 하드코딩 기준이 아니다.**
```
계산식:
한 줄 글자 수 = 블록 너비(px) ÷ (폰트 크기(px) × 0.97)
줄 수 = 블록 높이(px) ÷ (폰트 크기(px) × 줄간격)
총 글자 수 = 한 줄 글자 수 × 줄 수 × 안전 계수(0.85)
```
Pretendard 폰트 크기별 참고값 (1회 측정, 상수 저장):
| 폰트 크기 | 한글 글자 너비 | 줄간격 1.6 기준 줄 높이 |
|----------|-------------|---------------------|
| 12px | ~11.6px | 19.2px |
| 16px | ~15.5px | 25.6px |
| 20px | ~19.4px | 32.0px |
| 24px | ~23.3px | 38.4px |
---
## 디자인 원칙 (절대 규칙) ## 디자인 원칙 (절대 규칙)
### DO (해야 하는 것) ### DO (해야 하는 것)
@@ -171,16 +337,15 @@ Opus가 콘텐츠를 분석하여 아래 유형으로 분류한다.
- 폰트 크기 체계를 일관되게 유지 (제목/소제목/본문/캡션 4단계) - 폰트 크기 체계를 일관되게 유지 (제목/소제목/본문/캡션 4단계)
- 흑백 기조 + 포인트 컬러 최소 사용 - 흑백 기조 + 포인트 컬러 최소 사용
- 정보 계층을 시각적으로 명확히 표현 - 정보 계층을 시각적으로 명확히 표현
- 한 슬라이드에 메시지는 1개
### DON'T (하지 않는 것) ### DON'T (하지 않는 것)
- 그라데이션 배경 금지 - 그라데이션 배경 금지
- CSS 애니메이션/트랜지션 금지 - CSS 애니메이션/트랜지션 금지
- 호버 효과 금지 - 호버 효과 금지
- 그림자(box-shadow) 최소화 (1개 레벨만) - 그림자(box-shadow) 최소화 (1개 레벨만)
- 원본 콘텐츠를 전부 넣으려 하지 않는다 (70% 버려라)
- 다크 테마 금지 (요청하지 않는 한) - 다크 테마 금지 (요청하지 않는 한)
- 둥근 모서리 과다 사용 금지 (border-radius 최대 8px) - 둥근 모서리 과다 사용 금지 (border-radius 최대 8px)
- 텍스트를 자르지 않는다 (디자인이 텍스트에 맞춘다)
--- ---
@@ -189,98 +354,84 @@ Opus가 콘텐츠를 분석하여 아래 유형으로 분류한다.
```css ```css
:root { :root {
/* 색상 */ /* 색상 */
--color-primary: #1e293b; /* 메인 (짙은 남색) */ --color-primary: #1e293b;
--color-accent: #2563eb; /* 포인트 (파랑) */ --color-accent: #2563eb;
--color-neutral: #64748b; /* 중성 (회색) */ --color-neutral: #64748b;
--color-bg: #ffffff; /* 배경 */ --color-bg: #ffffff;
--color-bg-subtle: #f8fafc; /* 보조 배경 */ --color-bg-subtle: #f8fafc;
--color-border: #e2e8f0; /* 테두리 */ --color-border: #e2e8f0;
--color-danger: #dc2626; /* 경고/문제 */ --color-danger: #dc2626;
/* 폰트 크기 */ /* 폰트 크기 */
--font-title: 2rem; /* 슬라이드 제목 */ --font-title: 2rem;
--font-subtitle: 1.25rem; /* 섹션 제목 */ --font-subtitle: 1.25rem;
--font-body: 0.95rem; /* 본문 */ --font-body: 0.95rem;
--font-caption: 0.8rem; /* 캡션/출처 */ --font-caption: 0.8rem;
/* 여백 */ /* 여백 */
--spacing-page: 40px; /* 페이지 패딩 */ --spacing-page: 40px;
--spacing-block: 20px; /* 블록 간 간격 */ --spacing-block: 20px;
--spacing-inner: 16px; /* 블록 내부 패딩 */ --spacing-inner: 16px;
/* 기타 */ /* 기타 */
--radius: 6px; /* 둥근 모서리 */ --radius: 6px;
--border-width: 1px; /* 테두리 두께 */ --border-width: 1px;
--accent-border: 3px; /* 강조 테두리 */ --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 연동 ## Kei API 연동
### 연동 방식
- Design Agent는 Kei Persona 서버(`localhost:8000`)의 API를 호출하여 콘텐츠 분석을 요청한다
- Kei 서버가 떠있어야 Design Agent가 동작한다
- 향후 글벗에 붙일 때도 같은 API 호출 방식
### 호출 포인트 ### 호출 포인트
| 단계 | API | 용도 | | 단계 | API | 용도 |
|------|-----|------| |------|-----|------|
| 1단계 콘텐츠 분류 | Kei API (Opus) | 콘텐츠 유형 판단 + 배치 방향 | | 1단계 꼭지 추출 | Anthropic API (Sonnet) | 꼭지 추출 + 레이어 + 강조 + 배치 + 이미지/표/상세 판단 |
| 2단계 콘텐츠 선별 | Kei API (Sonnet) | 핵심 추출 + 슬롯 채우기 | | 2단계 레이아웃 설계 | Anthropic API (Sonnet) | 블록 매핑 + 공간 배분 + 글자 수 가이드 |
| 3단계 렌더링 | 로컬 (CSS Grid) | HTML 생성 (API 불필요) | | 3단계 텍스트 정리 | Anthropic API (Sonnet) | 의미 보존 편집 + 표 편집 + 자세히보기 작성 |
| 4단계 디자인 조정 + 조립 | Anthropic API (Sonnet) + Jinja2/CSS | 텍스트에 맞게 디자인 조정 + HTML 생성 |
| 5단계 재검토 | Anthropic API (Sonnet) | 균형 점검 + 2차 조정 |
### 독립 실행 가능 ### 독립 실행 가능
- Kei API 없이도 2-3단계만으로 동작 가능 (사용자가 직접 유형 선택) - Kei API 없이 Anthropic API 직접 호출로 동작
- Kei API 연결 시 1단계 자동화 - Kei API 연결 시 1단계에서 Kei RAG 지식 활용 가능
--- ---
## 기술 스택 (예정) ## 교본 (레퍼런스) 관리
### catalog.yaml
- 디자인 팀장의 "메뉴판"
- 각 블록의 id, 시각 설명, 언제 쓰는지(when), 슬롯 목록
- Figma에서 디자인 추출 → 템플릿 변환 → catalog에 등록
- **Figma 작업 완료 후 연동 예정**
### 저장 위치
```
design_agent/
├── templates/
│ ├── catalog.yaml ← AI용 블록 메뉴판 (Figma 작업 후)
│ ├── slide-base.html ← 슬라이드 베이스
│ └── blocks/ ← 블록 템플릿
├── samples/ ← 완성 슬라이드 샘플
└── docs/ ← 조사 자료
```
---
## 기술 스택
| 역할 | 도구 | 비고 | | 역할 | 도구 | 비고 |
|------|------|------| |------|------|------|
| 프론트엔드 | React + Vite | Kei와 동일 스택 | | 서버 | FastAPI + uvicorn | 포트 8001 |
| 렌더링 | CSS Grid + 디자인 토큰 | 순수 CSS, 프레임워크 없음 | | 템플릿 | Jinja2 | 블록 조합 |
| AI 콘텐츠 분석 | Kei API (Opus + Sonnet) | localhost:8000 | | 렌더링 | CSS Grid + 디자인 토큰 | 16:9 고정 |
| 출력 | HTML 다운로드 | PDF 불필요 | | 폰트 | Pretendard Variable | word-break: keep-all |
| AI | Anthropic API (Sonnet) | 5단계 모두 |
| 이미지 크기 | Pillow Image.open().size | 헤더만 읽음 |
| 자세히보기 | `<details>/<summary>` | HTML 내장 |
| 출력 | HTML 다운로드 | |
--- ---
@@ -295,27 +446,13 @@ D:\ad-hoc\kei\design_agent\
C) 둘 다 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 코드를 수정하지 않는다 1. Kei Persona Agent 코드를 수정하지 않는다
2. 디자인 판단을 하드코딩하지 않는다 (Opus/Sonnet이 사고한다) 2. 디자인 판단을 하드코딩하지 않는다 (AI가 사고한다)
3. 전체 페이지를 하나의 고정 템플릿으로 만들지 않는다 (블록 조합 방식) 3. 전체 페이지를 하나의 고정 템플릿으로 만들지 않는다 (블록 조합 방식)
4. 콘텐츠를 전부 넣으려 하지 않는다 (핵심만 추출) 4. 텍스트를 자르지 않는다 (디자인이 텍스트에 맞춘다)
5. 그라데이션, 애니메이션, 다크 테마를 기본으로 사용하지 않는다 5. 이미지를 crop하지 않는다 (크기만 조절)
6. 교본 없이 자유 디자인을 하지 않는다 (교본 참조 필수) 6. 그라데이션, 애니메이션, 다크 테마를 기본으로 사용하지 않는다

73
PLAN.md
View File

@@ -77,35 +77,57 @@
## Phase 3: AI 파이프라인 연결 ## Phase 3: AI 파이프라인 연결
### DA-12: Kei API 연동 — 콘텐츠 분류 (Opus) ### DA-12: 1단계 — Kei 실장 (꼭지 추출 + 분석)
- **파일:** src/kei_client.py - **파일:** src/kei_client.py
- **내용:** Kei API (`localhost:8000/api/message`)에 콘텐츠 전송 → Opus 분류 결과 수신. Kei API 미연결 시 수동 분류 fallback - **내용:** 본문에서 핵심 꼭지 추출 + 다단계 분석
- 꼭지 추출: 본문에서 2~5개 핵심 파트 식별 (1페이지 적정: 5개)
- 페이지 분리: 5개 초과 + 레이어 동등 → 2페이지 (의미 기반 분할 2/3, 3/4 등)
- 5개 + 내용 많음 → 세부 내용은 자세히보기 대상
- 레이어 수준: 각 꼭지가 도입/핵심/보조/결론 중 어디인지
- 강조 판단: 어떤 꼭지를 시각적으로 눈에 띄게 할 것인가
- 배치 방향: 세로로 긴 꼭지, 가로로 나열할 꼭지 판단
- 이미지 판단: 몇 개, 어떤 꼭지 소속, 핵심/보조, 텍스트 포함 여부
- 표 판단: 행/열 규모, 전체 표시 가능 여부
- 상세 콘텐츠 판단: 자세히보기 대상 식별
- **기술:** Anthropic API (Sonnet)
- **의존성:** DA-2 - **의존성:** DA-2
- **완료 기준:** 테스트 콘텐츠 전송 → 유형 분류 JSON 반환 - **완료 기준:** 꼭지 목록 + 레이어 + 강조 + 배치 + 이미지/표/상세 판단 JSON
### DA-13: 디자인 팀장 레이아웃 컨셉 (Sonnet) ### DA-13: 2단계 — 디자인 팀장 (레이아웃 설계)
- **파일:** src/design_director.py - **파일:** src/design_director.py
- **내용:** Anthropic API 직접 호출. Opus 분류 결과 + 원본 콘텐츠 → 레이아웃 컨셉만 결정. 텍스트 정리 안 함. - **내용:** 블록 매핑 + 공간 배분 + 글자 수 가이드
- **출력:** 블록 배치 + 페이지 수 + 슬롯 목록 (텍스트 없이 구조만) - 블록 매핑: catalog 메뉴판에서 각 꼭지 성격에 맞는 블록 선택
- **기술:** Anthropic API (Sonnet), JSON 반환 - 이미지 배치: Pillow로 원본 크기 확인 → 가로/세로에 따라 영역 결정 (크기만 조절, crop 안 함)
- 표 배치: 행×열 규모 보고 공간 판단 (안 되면 요약 요청 또는 페이지 분리)
- 자세히보기: 상세 콘텐츠는 `<details>` 영역으로 설계
- 공간 배분: 영역별 비율, 겹침 방지
- 글자 수 가이드: 각 블록 공간에 맞는 대략적 글자 수 (하드코딩 아닌 판단)
- 페이지 판단: 안 들어가면 2페이지 분리
- **기술:** Anthropic API (Sonnet) + Pillow (이미지 크기)
- **의존성:** DA-12 - **의존성:** DA-12
- **완료 기준:** "이 파트는 카드로, 이건 비교로, 2페이지 필요" 수준의 컨셉 JSON 반환 - **완료 기준:** 블록 배치 + 이미지/표 배치 + 글자 수 가이드 JSON
### DA-13b: 텍스트 편집자 — 슬롯 텍스트 정리 (Kei 역할) ### DA-13b: 3단계 — Kei 텍스트 편집자 (텍스트 정리)
- **파일:** src/content_editor.py (신규) - **파일:** src/content_editor.py
- **내용:** Anthropic API 직접 호출. 디자인 팀장의 레이아웃 컨셉 + 원본 콘텐츠 → 각 슬롯에 맞는 텍스트 편집. 도메인 지식 보존, 핵심 유지. - **내용:** 팀장의 글자 수 가이드 참고하되 내용 의미 우선
- **역할:** 도메인 전문가로서 콘텐츠를 정리하는 편집자 (Kei persona 규칙 일부 적용) - 전체 컨텍스트와 핵심 용어 유지
- **규칙:** 핵심 내용 유지, 개조식, 출처 보존, 슬롯 분량 준수, 내용 날조 금지 - 세련된 표현으로 편집 (의미 > 글자 수)
- **기술:** Anthropic API (Sonnet), JSON 반환 - 출처 보존, 개조식, 날조 금지
- 표 내용 편집 (핵심 행/열 선택, 요약)
- 자세히보기 대상: 요약 버전 + 상세 버전 둘 다 작성
- **기술:** Anthropic API (Sonnet)
- **의존성:** DA-13 - **의존성:** DA-13
- **완료 기준:** 슬롯별 텍스트가 채워진 JSON 반환. 원본 핵심 용 보존 확인. - **완료 기준:** 슬롯별 텍스트 JSON. 핵심 용 보존. 자세히보기 포함.
### DA-14: 전체 파이프라인 연결 (3단계) ### DA-14: 4단계 — 디자인 실무자 (디자인 조정 + HTML 조립) + 5단계 재검토
- **파일:** src/pipeline.py - **파일:** src/pipeline.py, src/renderer.py
- **내용:** 콘텐츠 입력 → Opus 분류 → 디자인 팀장 컨셉 → 텍스트 편집자 정리 → 렌더러 조립 → HTML 출력 - **내용:**
- **기술:** 순차 호출, 다중 페이지 지원 - 4단계 (AI + 코드): 편집자 텍스트에 맞게 디자인 조정 (폰트/여백/박스 — 텍스트 자르지 않음)
+ Jinja2 HTML 조립, 이미지 object-fit:contain, 표 container query, `<details>` 접기, 인쇄 펼침 JS
- 5단계 (AI): 팀장이 전체 균형 재검토 → 채움 비율, 블록 균형, 이미지/표 크기 점검 → 2차 조정
- **기술:** Anthropic API Sonnet (디자인 조정 + 재검토) + Jinja2/CSS (조립)
- **의존성:** DA-11, DA-12, DA-13, DA-13b - **의존성:** DA-11, DA-12, DA-13, DA-13b
- **완료 기준:** 텍스트 입력 → 완성 슬라이드 HTML 출력 (엔드투엔드, 다중 페이지 포함) - **완료 기준:** 텍스트 입력 → 균형 잡힌 슬라이드 HTML (이미지/표/자세히보기 포함, 재검토 완료)
--- ---
@@ -148,13 +170,14 @@
## 의존 관계 ## 의존 관계
``` ```
DA-1 → DA-2 → DA-12 → DA-13 ─┐ DA-1 → DA-2 → DA-12(실장) → DA-13(팀장) → DA-13b(편집자) ─┐
├→ DA-14 → DA-15 → DA-16 ├→ DA-14(조립+재검토) → DA-15 → DA-16
DA-3 → DA-4~DA-10 → DA-11 ────┘ DA-3 → DA-4~DA-10 → DA-11(렌더러) ─────────────────────────┘
``` ```
Phase 1(DA-1~3)과 Phase 2(DA-4~11)는 AI 없이 진행 가능. - Phase 1~2: AI 없이 진행 가능
Phase 3(DA-12~14)부터 Kei API + Anthropic API 필요. - Phase 3: Anthropic API 필요 (5단계 파이프라인)
- 5단계 흐름: 실장 → 팀장 → 편집자 → 조립 → 재검토
--- ---

View File

@@ -37,10 +37,10 @@
| 태스크 | 상태 | 담당 | 시작 | 완료 | 메모 | | 태스크 | 상태 | 담당 | 시작 | 완료 | 메모 |
|--------|------|------|------|------|------| |--------|------|------|------|------|------|
| DA-12: Kei API 연동 (Opus) | done | - | - | - | DA-2 이후 | | DA-12: 1단계 Kei 실장 (꼭지 추출+분석) | todo | - | - | - | 2~5개 꼭지 + 레이어 + 강조 + 이미지/표/상세 판단. 페이지 분리 |
| DA-13: 디자인 팀장 레이아웃 컨셉만 | todo | - | - | - | 기존에서 텍스트 정리 제거. 컨셉만 반환 | | DA-13: 2단계 디자인 팀장 (레이아웃 설계) | todo | - | - | - | 블록 매핑 + 이미지/표 배치 + 공간 배분 + 글자 수 가이드 |
| DA-13b: 텍스트 편집자 (Kei 역할) | todo | - | - | - | 신규. 도메인 전문가로 슬롯 텍스트 정리 | | DA-13b: 3단계 텍스트 편집자 (Kei 역할) | todo | - | - | - | 의미 우선 편집 + 표 편집 + 자세히보기(요약+상세) |
| DA-14: 전체 파이프라인 (3단계) | todo | - | - | - | 분류→컨셉→텍스트→렌더링. 다중 페이지 | | DA-14: 4단계 실무자(AI+코드) + 5단계 재검토 | todo | - | - | - | 디자인 조정 + HTML 조립 + 팀장 균형 재검토 |
## Phase 4: UI + 출력 ## Phase 4: UI + 출력

View File

@@ -2,7 +2,20 @@
## 목적 ## 목적
Figma 디자인(바론컨설턴트 홈페이지 기획팀 공유)에서 재사용 가능한 슬라이드 콘텐츠 블록을 추출하고, 디자인 팀장(Sonnet)이 선택할 수 있는 카탈로그로 체계화한다. Figma 디자인(바론컨설턴트 홈페이지 기획팀 공유)에서 재사용 가능한 **콘텐츠 블록**을 추출하고, 디자인 팀장(Sonnet)이 선택할 수 있는 카탈로그로 체계화한다.
**핵심 원칙: 블록은 모드 독립적이다.**
- 블록 자체는 "슬라이드 전용"이 아니라 **HTML/CSS 콘텐츠 블록**
- 슬라이드 모드(100vh, overflow:hidden)와 웹/스크롤 모드(auto, overflow:visible)는 **컨테이너(base 템플릿)**가 결정
- 블록은 높이를 고정하지 않음 → 어떤 컨테이너에도 들어갈 수 있음
- 현재는 `slide-base.html`(슬라이드)에 집중하되, 향후 `page-base.html`(웹) 추가 가능
```
블록 (카드, 표, 인용 등) — 모드와 무관, 재사용 가능
slide-base.html → height:100vh, overflow:hidden (슬라이드 모드)
page-base.html → height:auto, overflow:visible (웹/스크롤 모드, 향후)
```
--- ---
@@ -57,6 +70,8 @@ Figma 디자인(바론컨설턴트 홈페이지 기획팀 공유)에서 재사
| **아이콘 리스트** | ❌ | ✅ (아이콘+제목+설명) | **신규** | | **아이콘 리스트** | ❌ | ✅ (아이콘+제목+설명) | **신규** |
| **Hero 섹션** | ❌ | ✅ (배경+원형이미지+텍스트) | **신규** | | **Hero 섹션** | ❌ | ✅ (배경+원형이미지+텍스트) | **신규** |
| **CTA 버튼 바** | ❌ | ✅ (자세히보기 버튼) | **필요 시** | | **CTA 버튼 바** | ❌ | ✅ (자세히보기 버튼) | **필요 시** |
| **이미지 블록** | ❌ | ✅ (도표, 참고자료) | **신규** (3변형: full/side/thumb) |
| **자세히보기 블록** | ❌ | ✅ (상세 콘텐츠 접기/펼치기) | **신규** (`<details>/<summary>`) |
--- ---
@@ -66,19 +81,41 @@ Figma 디자인(바론컨설턴트 홈페이지 기획팀 공유)에서 재사
#### A-1: Figma 전체 섹션 이미지 렌더링 #### A-1: Figma 전체 섹션 이미지 렌더링
- **작업:** 각 섹션/프레임을 이미지로 렌더링하여 시각적으로 패턴 식별 - **작업:** 각 섹션/프레임을 이미지로 렌더링하여 시각적으로 패턴 식별
- **방법:** Figma API `/v1/images/{file_key}?ids={node_ids}` - **방법:**
- **1차:** Framelink MCP `get_figma_data` — CSS-ready 데이터로 노드 구조 + 스타일 동시 추출
- **2차:** Figma 공식 MCP `get_screenshot` — 시각 참고용 스크린샷
- **fallback:** Figma REST API `/v1/images/{file_key}?ids={node_ids}` (MCP 미설치 시)
- **산출물:** `docs/figma-screenshots/` 폴더에 PNG 저장 - **산출물:** `docs/figma-screenshots/` 폴더에 PNG 저장
- **완료 기준:** 모든 자세히보기 프레임(8개)의 스크린샷 확보 - **완료 기준:** 모든 자세히보기 프레임(8개)의 스크린샷 확보
#### A-2: Figma 노드 구조 심층 분석 #### A-2: Figma 노드 구조 심층 분석
- **작업:** 각 프레임의 depth=5 수준까지 노드 트리 분석 - **작업:** 각 프레임의 상세 스타일 + 레이아웃 정보 추출
- **방법:** Figma API `/v1/files/{key}/nodes?ids={ids}&depth=5` - **방법:**
- **1차:** Framelink MCP `get_figma_data` (nodeId 지정, depth 조절)
- 자동 CSS 변환: 색상→hex/rgba, 레이아웃→flex 용어, 그림자→box-shadow, 그라데이션→linear-gradient()
- 스타일 중복 제거 (글로벌 변수로 추출)
- 토큰 효율적 (raw API 대비 1/5 크기)
- **2차:** Figma 공식 MCP `get_variable_defs` — 디자인 토큰/변수 추출
- **fallback:** Figma REST API `/v1/files/{key}/nodes?ids={ids}&depth=5` (MCP 미설치 시)
- **추출 정보:** - **추출 정보:**
- TEXT 노드: 폰트, 크기, 색상, 내용 - TEXT 노드: fontFamily, fontSize, fontWeight, lineHeight, letterSpacing, color, 텍스트 내용
- FRAME/GROUP: 레이아웃 방식 (auto-layout, constraints) - FRAME/GROUP: auto-layout (direction, gap, padding, alignItems, justifyContent), constraints
- RECTANGLE: 배경색, 테두리, 둥근 모서리 - RECTANGLE: fills (solid/gradient/image), strokes, cornerRadius, effects
- INSTANCE: 재사용 컴포넌트 식별 - INSTANCE: componentId (재사용 컴포넌트 식별)
- **Figma → CSS 매핑 (Framelink MCP가 자동 처리, REST API 시 수동):**
- `layoutMode: "VERTICAL"``flex-direction: column`
- `primaryAxisAlignItems: "CENTER"``justify-content: center`
- `itemSpacing: 20``gap: 20px`
- `paddingLeft/Right/Top/Bottom``padding`
- `fills[].color {r,g,b,a}``rgba()` 또는 `#hex`
- `fills[].type: "GRADIENT_LINEAR"``linear-gradient(...)`
- `cornerRadius``border-radius`
- `strokes + strokeWeight``border`
- `effects[].type: "DROP_SHADOW"``box-shadow`
- `fontSize``font-size` (px 단위)
- `lineHeightPercentFontSize: 170``line-height: 1.7`
- **산출물:** `docs/figma-analysis/` 폴더에 구조 문서 - **산출물:** `docs/figma-analysis/` 폴더에 구조 문서
- **주의:** Figma API rate limit 심함 — depth 깊은 요청은 30분 차단 가능. 얕게 요청 후 필요한 노드만 상세 조회
#### A-3: 디자인 패턴 분류 + 명명 #### A-3: 디자인 패턴 분류 + 명명
- **작업:** 추출된 시각 요소를 재사용 가능한 블록 단위로 분류 - **작업:** 추출된 시각 요소를 재사용 가능한 블록 단위로 분류
@@ -90,20 +127,50 @@ Figma 디자인(바론컨설턴트 홈페이지 기획팀 공유)에서 재사
### Phase B: HTML/CSS 컴포넌트 제작 ### Phase B: HTML/CSS 컴포넌트 제작
#### B-1: 신규 블록 템플릿 제작 (6~8종) #### B-1: 신규 블록 템플릿 제작 (8~10종)
- **파일:** `templates/blocks/{name}.html` - **파일:** `templates/blocks/{name}/` 폴더별 정리 (변형별 파일 + preview.png)
- **제작 순서 (우선순위):** - **제작 순서 (우선순위):**
1. `section-title.html` — 공통 헤더 (모든 슬라이드에서 사용) 1. `section-title/default.html` — 공통 헤더 (모든 슬라이드에서 사용)
2. `example-card.html` — 사례 카드 (출처+불릿, 정책 문서 인용) 2. `example-card/2col.html` — 사례 카드 (출처+불릿, 정책 문서 인용)
3. `image-gallery.html` — 이미지 갤러리 (2~4장, 근거 자료) 3. `image-block/full.html`, `side.html`, `thumb.html` — 이미지 블록 3변형
4. `timeline.html` — 타임라인 (세로/가로, 연혁/로드맵) - full: 전체 너비 (핵심 도표, 가로형)
5. `big-number.html` — 핵심 지표 (큰 숫자 + 보조 텍스트) - side: 텍스트 옆 (보조 이미지, 세로형)
6. `icon-list.html` — 아이콘 리스트 (아이콘+제목+설명, 기능 나열) - thumb: 썸네일 (참고 문서 표지)
- CSS: `object-fit: contain` (비율 유지, 잘리지 않음)
- 원본 이미지 그대로 사용, 크기만 조절 (crop 안 함)
4. `image-gallery/2col.html`, `3col.html`, `2x2.html` — 이미지 갤러리
5. `timeline/vertical.html`, `horizontal.html` — 타임라인 (연혁/로드맵)
6. `big-number/3col.html`, `4col.html` — 핵심 지표 (큰 숫자 + 보조 텍스트)
7. `icon-list/vertical.html`, `grid.html` — 아이콘 리스트 (기능 나열)
8. `details-block/default.html` — 자세히보기 (`<details>/<summary>`)
- 슬라이드 표면: 요약만 표시
- 펼치면: 전체 상세 내용
- 인쇄 시: `beforeprint` 이벤트로 자동 펼침
- **규칙:** - **규칙:**
- 디자인 토큰(`var(--color-*)`) 사용 (하드코딩 색상 금지) - 디자인 토큰(`var(--color-*)`) 사용 (하드코딩 색상 금지)
- Jinja2 슬롯 (`{{ variable }}`) 형식 - Jinja2 슬롯 (`{{ variable }}`) 형식
- `<style>` 태그를 블록 HTML 안에 포함 (자체 완결) - `<style>` 태그를 블록 HTML 안에 포함 (자체 완결)
- Figma 원본과 시각적으로 유사하되 1:1 복제 아님 (디자인 토큰 기준) - Figma 원본과 시각적으로 유사하되 1:1 복제 아님 (디자인 토큰 기준)
- **높이를 고정하지 않음** (auto 또는 비율 사용) — 모드 독립적
- 각 블록에 사람용 주석 포함 (용도, 적합/부적합, Figma 원본 위치)
#### B-1a: Figma 웹 → 블록 변환 규칙
Figma는 웹사이트 디자인(920px 너비, 세로 스크롤)이므로, 블록으로 변환할 때 아래 규칙을 적용한다:
| Figma 원본 | 블록 변환 | 이유 |
|------------|----------|------|
| 높이: 고정 px (예: 200px) | `height: auto` 또는 비율(%) | 720px 안에 들어가야 하고, 웹 모드에서도 동작해야 함 |
| 너비: 고정 px (예: 920px) | `fr` 또는 `%` (grid 컬럼에 맞춤) | 컨테이너 너비에 적응해야 함 |
| 색상: Figma 값 (예: #1565c0) | `var(--color-accent)` 등 디자인 토큰 | 토큰 체계 통일. 필요 시 `tokens.css`에 토큰 추가 |
| 폰트: Figma 값 (예: Noto Sans CJK KR 24px) | `var(--font-subtitle)` 등 디자인 토큰 | Pretendard 기준. 크기 비율만 참고 |
| 간격: Figma 값 (예: padding 20px, gap 80px) | `var(--spacing-inner)`, `var(--spacing-block)` 등 | 토큰 체계. 비율 참고 |
| 그라데이션 배경 | **제거** | CLAUDE.md 디자인 규칙: 그라데이션 금지 |
| 호버/클릭 효과 | **제거** | CLAUDE.md 디자인 규칙: 호버 금지 |
| 애니메이션/트랜지션 | **제거** | CLAUDE.md 디자인 규칙: 애니메이션 금지 |
| 원형 이미지 마스크 | **제거** (필요 시 별도 변형으로) | 장식 요소는 기본에서 제외 |
| 텍스트 위치 | `{{ variable }}` Jinja2 슬롯으로 표시 | 교체 가능한 부분 |
**원칙:** Figma에서 가져오는 것은 **정보 구조(카드 배치, 타임라인 흐름, 비교 레이아웃)**이지, 장식 요소가 아니다.
#### B-2: 기존 블록 변형 추가 #### B-2: 기존 블록 변형 추가
- **대상:** - **대상:**
@@ -268,10 +335,29 @@ layouts:
| 리스크 | 설명 | 대응 | | 리스크 | 설명 | 대응 |
|--------|------|------| |--------|------|------|
| **색상 불일치** | Figma는 파란 그라데이션 배경 사용, 디자인 토큰은 `#2563eb` 단색 | Figma 색상을 참고하되 토큰 체계 우선. 필요 시 토큰 추가 (`--color-accent-light` 등) | | **색상 불일치** | Figma는 파란 그라데이션 배경 사용, 디자인 토큰은 `#2563eb` 단색 | Figma 색상을 참고하되 토큰 체계 우선. 필요 시 토큰 추가 (`--color-accent-light` 등) |
| **폰트 불일치** | Figma는 특정 폰트(웹폰트 아닐 수 있음), 토큰은 Pretendard | Pretendard 유지. Figma 폰트 크기 비율만 참고 | | **폰트 불일치** | Figma는 Noto Sans CJK KR 사용, 토큰은 Pretendard Variable | Pretendard 유지. Figma 폰트 크기 비율만 참고 |
| **여백 불일치** | Figma 920px 프레임 vs 슬라이드 1280px | 비율 기반으로 변환 (920:1280 = 0.72배) | | **여백 불일치** | Figma 920px 프레임 vs 슬라이드 1280px | 비율 기반으로 변환. 고정 px 대신 토큰 사용 |
| **웹 vs 슬라이드** | Figma는 웹사이트(세로 스크롤, 920x1231~2208px), 슬라이드는 고정(1280x720px) | 높이 고정하지 않음(auto). 컨테이너가 모드를 결정 |
**원칙:** Figma 디자인을 1:1 복제하지 않는다. 패턴(구조)만 추출하고, 스타일은 디자인 토큰으로 통일한다. **원칙:** Figma 디자인을 1:1 복제하지 않는다. 패턴(정보 구조)만 추출하고, 스타일은 디자인 토큰으로 통일한다.
**디자인 토큰 매핑 테이블 (Figma 추출 시 작성):**
Figma에서 추출한 색상/폰트/간격을 기존 `tokens.css`와 매핑하고, 누락된 토큰이 있으면 추가한다.
| Figma 속성 | 추출값 (예시) | 기존 토큰 매핑 | 신규 토큰 필요? |
|------------|-------------|--------------|--------------|
| 배경색 (SOLID fill) | `#f8fafc` | `--color-bg-subtle` | - |
| 포인트 색상 | `#1565c0` | `--color-accent` (현재 `#2563eb`) | 검토 필요 |
| 텍스트 색상 | `#1e293b` | `--color-primary` | - |
| 보조 텍스트 | `#64748b` | `--color-neutral` | - |
| 경고/강조 | `#dc2626` | `--color-danger` | - |
| 본문 폰트 크기 | `16px` | `--font-body` (현재 `0.95rem ≈ 15.2px`) | 비율 확인 |
| 제목 폰트 크기 | `24px` | `--font-subtitle` (현재 `1.25rem = 20px`) | 비율 확인 |
| 블록 간 간격 | `20px` | `--spacing-block` | - |
| 내부 패딩 | `16px` | `--spacing-inner` | - |
| 카드 상단 액센트 | `3px solid` | `--accent-border` | - |
이 매핑 테이블은 Phase A-2에서 실제 Figma 값을 추출한 후 정확한 값으로 채운다. 기존 토큰과 차이가 크면 토큰을 추가하되, 기존 토큰의 값을 변경하지 않는다 (기존 7개 블록이 깨질 수 있음).
### 2. 블록 개수 증가 → 디자인 팀장 혼란 ### 2. 블록 개수 증가 → 디자인 팀장 혼란
@@ -296,10 +382,37 @@ layouts:
| 리스크 | 설명 | 대응 | | 리스크 | 설명 | 대응 |
|--------|------|------| |--------|------|------|
| **CSS 미제공** | Figma API는 CSS를 직접 제공하지 않음. 스타일 속성(fill, fontSize 등)만 제공 | 스타일 속성에서 CSS 수동 변환. 복잡한 것은 스크린샷 보고 직접 작성 | | **CSS 미제공** | Figma REST API는 CSS를 직접 제공하지 않음. 스타일 속성만 제공 | **Framelink MCP 사용 시 자동 CSS 변환.** REST API만 사용 시 수동 변환 필요 |
| **이미지 에셋** | 벡터(VECTOR, ELLIPSE)는 PNG로 렌더링 가능하나 CSS 재현 필요 | 단순 도형은 CSS로 재현, 복잡한 것은 PNG export 후 img 태그 | | **이미지 에셋** | 벡터(VECTOR, ELLIPSE)는 PNG로 렌더링 가능하나 CSS 재현 필요 | **Framelink MCP `download_figma_images`로 일괄 export.** 단순 도형은 CSS, 복잡한 것은 PNG |
| **INSTANCE 참조** | Figma 컴포넌트(Instance)의 master 확인 필요 | `GET /v1/files/{key}/components`로 마스터 컴포넌트 조회 | | **INSTANCE 참조** | Figma 컴포넌트(Instance)의 master 확인 필요 | `GET /v1/files/{key}/components`로 마스터 컴포넌트 조회 |
| **Rate Limit** | Figma API rate limit 존재 | 한 번에 대량 호출 자제, 결과 캐싱 | | **Rate Limit** | REST API depth 깊은 요청 시 30분 차단 가능 | **Framelink MCP가 토큰 효율적 (raw API 대비 1/5).** 얕게 요청 후 필요한 노드만 상세 조회. 결과 캐싱 |
| **디자인 토큰 접근** | REST API로는 Figma 변수/토큰 추출 불가 (별도 scope 필요) | **Figma 공식 MCP `get_variable_defs` 사용.** 또는 노드 속성에서 수동 추출 |
| **의미적 HTML 추론 불가** | "버튼"은 FRAME+TEXT, "카드"는 FRAME+FRAME — 태그 구분 없음 | 노드 이름(naming convention)으로 추론. 예: `section_title`, `example-card` |
#### MCP 도구 설정
**Framelink MCP (권장, CSS-ready 추출):**
```json
// Claude Code MCP 설정
{
"mcpServers": {
"Framelink MCP for Figma": {
"command": "cmd",
"args": ["/c", "npx", "-y", "figma-developer-mcp", "--figma-api-key=YOUR-KEY", "--stdio"]
}
}
}
```
- `get_figma_data`: 노드 스타일을 CSS-ready YAML로 변환 (색상→hex, 레이아웃→flex, 그림자→box-shadow)
- `download_figma_images`: 이미지 일괄 다운로드 (크롭, 중복 제거 자동)
**Figma 공식 MCP (디자인 토큰 + 스크린샷):**
```
claude mcp add --transport http figma https://mcp.figma.com/mcp
```
- `get_variable_defs`: 디자인 토큰(색상, 간격, 타이포) 추출
- `get_screenshot`: 시각 참고용 스크린샷
- 주의: Free/Starter 플랜 = 월 6회 호출 제한
### 5. Starlight(.astro) 연결 시 충돌 ### 5. Starlight(.astro) 연결 시 충돌
@@ -354,23 +467,69 @@ A-2 (노드 구조 분석) ──┘
``` ```
docs/ docs/
├── figma-screenshots/ # A-1: 각 프레임 PNG ├── figma-screenshots/ # A-1: 각 프레임 PNG
├── figma-analysis/ # A-2: 노드 구조 문서 ├── figma-analysis/ # A-2: 노드 구조 문서 + 디자인 토큰 매핑 테이블
└── FIGMA-COMPONENT-EXTRACTION-PLAN.md # 이 파일 └── FIGMA-COMPONENT-EXTRACTION-PLAN.md # 이 파일
templates/ templates/
├── catalog.yaml # C-1: 블록 카탈로그 (AI용 + 사람용) ├── catalog.yaml # C-1: 블록 카탈로그 (AI용 메뉴판 + 사람용 참고)
├── blocks/ ├── blocks/
│ ├── (기존 7개) │ ├── comparison/ # 기존 (폴더 구조로 재편)
├── section-title.html # B-1: 신규 │ ├── default.html
├── example-card.html # B-1: 신규 │ └── preview.png
│ ├── image-gallery.html # B-1: 신규 │ ├── card-grid/ # 기존
├── timeline.html # B-1: 신규 │ ├── default.html
├── big-number.html # B-1: 신규 │ ├── icon.html # B-2: 변형
├── icon-list.html # B-1: 신규 │ └── preview.png
│ ├── quote-block-decorated.html # B-2: 변형 │ ├── relationship/ # 기존
│ ├── card-grid-icon.html # B-2: 변형 │ ├── process/ # 기존
── comparison-visual.html # B-2: 변형 ── quote-block/ # 기존
└── slide-base.html # B-3: 업데이트 │ │ ├── default.html
│ │ ├── decorated.html # B-2: 변형
│ │ └── preview.png
│ ├── conclusion-bar/ # 기존
│ ├── comparison-table/ # 기존
│ ├── section-title/ # B-1: 신규
│ │ ├── default.html
│ │ └── preview.png
│ ├── example-card/ # B-1: 신규
│ │ ├── single.html
│ │ ├── 2col.html
│ │ ├── 3col.html
│ │ └── preview.png
│ ├── image-block/ # B-1: 신규 (3변형)
│ │ ├── full.html # 전체 너비 (핵심 도표)
│ │ ├── side.html # 텍스트 옆 (보조 이미지)
│ │ ├── thumb.html # 썸네일 (참고 문서)
│ │ └── preview.png
│ ├── image-gallery/ # B-1: 신규
│ │ ├── 2col.html
│ │ ├── 3col.html
│ │ ├── 2x2.html
│ │ └── preview.png
│ ├── timeline/ # B-1: 신규
│ │ ├── vertical.html
│ │ ├── horizontal.html
│ │ └── preview.png
│ ├── big-number/ # B-1: 신규
│ │ ├── 3col.html
│ │ ├── 4col.html
│ │ └── preview.png
│ ├── icon-list/ # B-1: 신규
│ │ ├── vertical.html
│ │ ├── grid.html
│ │ └── preview.png
│ └── details-block/ # B-1: 신규
│ ├── default.html
│ └── preview.png
├── slide-base.html # B-3: 업데이트 (슬라이드 모드)
└── page-base.html # 향후: 웹/스크롤 모드
samples/ # 완성 슬라이드 레시피
├── dx-bim-comparison/
│ ├── slide.html # 완성 HTML
│ ├── preview.png # 스크린샷
│ └── meta.yaml # 사용된 블록 조합 + Figma 원본 참조
└── ...
src/ src/
├── design_director.py # C-2: catalog.yaml 연동 ├── design_director.py # C-2: catalog.yaml 연동
@@ -379,10 +538,45 @@ src/
--- ---
## PROGRESS.md 연동
이 계획의 태스크들은 PROGRESS.md에 아래와 같이 등록한다:
### Phase F (Figma 컴포넌트 추출) — PROGRESS.md 등록 항목
| 태스크 | 상태 | 의존성 | 메모 |
|--------|------|--------|------|
| F-A1: Figma 스크린샷 확보 | todo | MCP 설치 후 | Framelink MCP 또는 REST API |
| F-A2: 노드 구조 심층 분석 | todo | F-A1 | CSS-ready 데이터 + 토큰 매핑 테이블 |
| F-A3: 패턴 분류 + 명명 | todo | F-A1, F-A2 | 블록 후보 목록 확정 |
| F-B1: 신규 블록 템플릿 제작 (8~10종) | todo | F-A3 | 폴더 구조, 변환 규칙 적용 |
| F-B1a: Figma 웹→블록 변환 규칙 검증 | todo | F-B1 | 토큰 매핑, 높이 auto 확인 |
| F-B2: 기존 블록 변형 추가 | todo | F-B1 | CSS만 변형, 슬롯 유지 |
| F-B3: slide-base.html 업데이트 | todo | F-B1 | 기존 7개 블록 깨지지 않는지 검증 |
| F-C1: catalog.yaml 생성 | todo | F-B1 | when/not_for/slots/char_limits |
| F-C2: 팀장 프롬프트 연결 | todo | F-C1 | catalog.yaml → 시스템 프롬프트 |
| F-C3: renderer.py 업데이트 | todo | F-C1 | 신규 블록 로드 + BLOCK_SLOTS 동기화 |
| F-T1: 기존 테스트 재실행 | todo | F-B3, F-C3 | DA-16 기존 케이스 전체 통과 확인 |
**사전 세팅 (Figma 작업 시작 전 필요):**
| 세팅 | 상태 | 설명 |
|------|------|------|
| Framelink MCP 설치 | todo | `npx figma-developer-mcp --figma-api-key=...` |
| Figma 공식 MCP 설치 | todo | `claude mcp add --transport http figma https://mcp.figma.com/mcp` |
| 기존 블록 폴더 구조 재편 | todo | 플랫 파일 → 블록별 폴더 (`templates/blocks/{name}/`) |
| preview.png 생성 방법 확보 | todo | HTML을 브라우저로 열어 스크린샷 (수동 또는 Playwright) |
---
## 금지 사항 ## 금지 사항
1. Figma 디자인을 1:1 복제하지 않는다 (패턴만 추출, 스타일은 토큰 기준) 1. Figma 디자인을 1:1 복제하지 않는다 (정보 구조만 추출, 장식은 제거, 스타일은 토큰 기준)
2. 기존 7개 블록 템플릿을 수정하지 않는다 (신규/변형은 별도 파일) 2. 기존 7개 블록 템플릿을 수정하지 않는다 (신규/변형은 별도 파일)
3. 한 번에 모든 블록을 만들지 않는다 (A-3 분류 결과를 보고 우선순위 재조정) 3. 한 번에 모든 블록을 만들지 않는다 (A-3 분류 결과를 보고 우선순위 재조정)
4. catalog.yaml 없이 블록을 추가하지 않는다 (카탈로그 미등록 = 디자인 팀장이 모름) 4. catalog.yaml 없이 블록을 추가하지 않는다 (카탈로그 미등록 = 디자인 팀장이 모름)
5. Kei Persona Agent 코드를 수정하지 않는다 5. Kei Persona Agent 코드를 수정하지 않는다
6. 블록의 높이를 고정 px로 하드코딩하지 않는다 (모드 독립적이어야 함)
7. 기존 `tokens.css`의 값을 변경하지 않는다 (신규 토큰 추가는 가능, 기존 값 변경은 7개 블록 깨짐 위험)
8. 이미지를 crop하지 않는다 (원본 그대로, 크기만 조절)
9. 그라데이션, 호버, 애니메이션 등 Figma 장식 요소를 가져오지 않는다

View File

@@ -1,8 +1,8 @@
"""DA-13b: 텍스트 편집자 — 슬롯 텍스트 정리 (Kei 역할). """DA-13b: 3단계 — Kei 텍스트 편집자 (텍스트 정리).
디자인 팀장의 레이아웃 컨셉 + 원본 콘텐츠를 받아, 디자인 팀장의 레이아웃 컨셉 + 원본 콘텐츠를 받아,
각 슬롯에 맞는 텍스트를 도메인 전문가로서 정리한다. 각 슬롯에 맞는 텍스트를 도메인 전문가로서 정리한다.
핵심 내용을 유지하면서 슬롯 분량에 맞게 편집. 팀장의 글자 수 가이드를 참고하되 내용 의미가 우선.
""" """
from __future__ import annotations from __future__ import annotations
@@ -18,20 +18,47 @@ from src.design_director import BLOCK_SLOTS
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
EDITOR_PROMPT = """당신은 도메인 전문가이자 콘텐츠 편집자이다.
원본 콘텐츠의 핵심 내용을 유지하면서 각 블록의 슬롯에 맞게 텍스트를 정리한다.
## 핵심 원칙
- **내용의 의미와 정확성이 글자 수보다 우선한다**
- 팀장이 제시한 글자 수 가이드는 참고. 의미를 살리려면 가이드를 초과해도 된다.
- 디자인 실무자가 텍스트에 맞게 디자인을 조정할 것이므로, 텍스트를 억지로 자르지 않는다.
## 편집 규칙
- 전체 컨텍스트와 핵심 용어를 보존한다
- 세련된 표현으로 편집한다 (원본 그대로가 아님)
- 개조식(불릿, 번호)으로 작성한다. 줄글 금지.
- 출처가 있는 내용은 출처를 반드시 보존한다
- 출처가 없는 수치나 통계를 만들지 않는다
## 표 편집 규칙
- 표는 표로 유지한다 (다른 형태로 전환하지 않음)
- 팀장이 요약 요청하면 핵심 행/열만 선택하고 "...외 N건" 표기
## 자세히보기 편집 규칙
- detail_target인 꼭지는 두 버전을 작성:
- summary: 슬라이드 표면에 보일 요약 (3줄 이내)
- detail: 펼치면 보일 전체 내용
## JSON 형식으로만 응답한다. 설명 없이 JSON만."""
async def fill_content( async def fill_content(
content: str, content: str,
layout_concept: dict[str, Any], layout_concept: dict[str, Any],
analysis: dict[str, Any] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""각 페이지의 각 블록 슬롯에 텍스트를 채운다. """3단계: 각 페이지의 각 블록 슬롯에 텍스트를 채운다.
Args: Args:
content: 원본 텍스트 콘텐츠 content: 원본 텍스트 콘텐츠
layout_concept: 디자인 팀장의 레이아웃 컨셉 layout_concept: 디자인 팀장의 레이아웃 컨셉
{"title": "...", "pages": [{"blocks": [...]}]} analysis: 1단계 실장의 꼭지 분석 결과 (참고용)
Returns: Returns:
슬롯이 채워진 layout_concept (pages[n].blocks[m].data에 텍스트 추가) 슬롯이 채워진 layout_concept
""" """
client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key) client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
@@ -40,51 +67,39 @@ async def fill_content(
if not blocks: if not blocks:
continue continue
# 슬롯 요구사항 생성 # 블록별 슬롯 + 글자 수 가이드 생성
slot_requirements = [] slot_requirements = []
for i, block in enumerate(blocks): for i, block in enumerate(blocks):
block_type = block["type"] block_type = block.get("type", "")
slots = BLOCK_SLOTS.get(block_type, {}) slots = BLOCK_SLOTS.get(block_type, {})
slot_requirements.append( char_guide = block.get("char_guide", {})
f"블록 {i+1} ({block_type}, 영역: {block['area']}):\n"
req_text = (
f"블록 {i+1} ({block_type}, 영역: {block.get('area', '?')}):\n"
f" 용도: {block.get('reason', '미지정')}\n"
f" 크기: {block.get('size', 'medium')}\n"
f" 필수 슬롯: {slots.get('required', [])}\n" f" 필수 슬롯: {slots.get('required', [])}\n"
f" 선택 슬롯: {slots.get('optional', [])}\n" f" 선택 슬롯: {slots.get('optional', [])}"
f" 용도: {block.get('reason', '미지정')}"
) )
system_prompt = ( if char_guide:
"당신은 도메인 전문가이자 콘텐츠 편집자이다.\n" guide_lines = [f" {k}: ~{v}" for k, v in char_guide.items()]
"원본 콘텐츠의 핵심 내용을 유지하면서 각 블록의 슬롯에 맞게 텍스트를 정리한다.\n\n" req_text += "\n 글자 수 가이드 (참고, 의미 우선):\n" + "\n".join(guide_lines)
"## 규칙\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 "" slot_requirements.append(req_text)
page_label = ""
if len(layout_concept.get("pages", [])) > 1:
page_label = f" (페이지 {page_idx + 1}/{len(layout_concept['pages'])})"
user_prompt = ( user_prompt = (
f"## 원본 콘텐츠\n{content}\n\n" f"## 원본 콘텐츠\n{content}\n\n"
f"## 블록 배치 {page_label}\n" f"## 블록 배치{page_label}\n"
+ "\n".join(slot_requirements) + "\n".join(slot_requirements)
+ "\n\n## 요청\n" + "\n\n## 요청\n"
"위 블록별로 슬롯에 들어갈 텍스트를 정리하여 JSON으로 반환해줘.\n" "위 블록별로 슬롯에 들어갈 텍스트를 정리하여 JSON으로 반환해줘.\n"
"원본의 핵심 내용을 충실하게 반영하되, 각 슬롯 분량에 맞게 편집해.\n" "내용의 의미를 살려서 편집해. 글자 수 가이드는 참고만.\n"
"자세히보기 대상 블록은 summary + detail 두 버전을 작성해.\n"
"형식:\n" "형식:\n"
'{"blocks": [{"area": "...", "type": "...", "data": {슬롯 키-값}}]}' '{"blocks": [{"area": "...", "type": "...", "data": {슬롯 키-값}}]}'
) )
@@ -93,7 +108,7 @@ async def fill_content(
response = await client.messages.create( response = await client.messages.create(
model="claude-sonnet-4-20250514", model="claude-sonnet-4-20250514",
max_tokens=4096, max_tokens=4096,
system=system_prompt, system=EDITOR_PROMPT,
messages=[{"role": "user", "content": user_prompt}], messages=[{"role": "user", "content": user_prompt}],
) )
@@ -103,7 +118,7 @@ async def fill_content(
if filled and "blocks" in filled: if filled and "blocks" in filled:
for filled_block in filled["blocks"]: for filled_block in filled["blocks"]:
for orig_block in blocks: for orig_block in blocks:
if orig_block["area"] == filled_block.get("area"): if orig_block.get("area") == filled_block.get("area"):
orig_block["data"] = filled_block.get("data", {}) orig_block["data"] = filled_block.get("data", {})
break break
@@ -112,7 +127,7 @@ async def fill_content(
f"{len(filled['blocks'])}개 블록" f"{len(filled['blocks'])}개 블록"
) )
else: else:
logger.warning(f"텍스트 정리 파싱 실패 (페이지 {page_idx + 1}). 기본값 사용.") logger.warning(f"텍스트 정리 파싱 실패 (페이지 {page_idx + 1}). 기본값.")
_apply_defaults(blocks) _apply_defaults(blocks)
except Exception as e: except Exception as e:
@@ -138,18 +153,20 @@ def _apply_defaults(blocks: list[dict[str, Any]]) -> None:
}, },
"process": {"steps": []}, "process": {"steps": []},
"comparison-table": {"headers": [], "rows": []}, "comparison-table": {"headers": [], "rows": []},
"image-block": {"src": "", "alt": "이미지"},
"details-block": {"summary_text": "(상세 내용)", "detail_content": ""},
} }
for block in blocks: for block in blocks:
if "data" not in block: if "data" not in block:
block["data"] = defaults.get(block["type"], {}) block["data"] = defaults.get(block.get("type", ""), {})
def _parse_json(text: str) -> dict[str, Any] | None: def _parse_json(text: str) -> dict[str, Any] | None:
"""텍스트에서 JSON을 추출한다.""" """텍스트에서 JSON을 추출한다."""
patterns = [ patterns = [
r'```json\s*(.*?)```', r"```json\s*(.*?)```",
r'```\s*(.*?)```', r"```\s*(.*?)```",
r'(\{.*\})', r"(\{.*\})",
] ]
for pattern in patterns: for pattern in patterns:
match = re.search(pattern, text, re.DOTALL) match = re.search(pattern, text, re.DOTALL)

View File

@@ -1,14 +1,15 @@
"""DA-13: 디자인 팀장 레이아웃 컨셉만 (Sonnet). """DA-13: 2단계 — 디자인 팀장 (레이아웃 설계).
Opus의 분류 결과 + 원본 콘텐츠를 받아, 실장의 꼭지 분석 결과를 받아,
레이아웃 컨셉(블록 + 페이지 수 + 슬롯 목록)만 결정한다. 각 꼭지에 적합한 블록을 매핑하고 공간 + 글자 수 가이드를 결정한다.
텍스트 정리는 하지 않는다 — content_editor가 담당. 텍스트 정리는 하지 않는다.
""" """
from __future__ import annotations from __future__ import annotations
import json import json
import logging import logging
import re import re
from pathlib import Path
from typing import Any from typing import Any
import anthropic import anthropic
@@ -17,7 +18,7 @@ from src.config import settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# 블록별 슬롯 정의 (content_editor에서도 참조) # 블록별 슬롯 정의 (content_editor, renderer에서도 참조)
BLOCK_SLOTS = { BLOCK_SLOTS = {
"comparison": { "comparison": {
"required": ["left_title", "left_content", "right_title", "right_content"], "required": ["left_title", "left_content", "right_title", "right_content"],
@@ -47,78 +48,147 @@ BLOCK_SLOTS = {
"required": ["headers", "rows"], "required": ["headers", "rows"],
"optional": [], "optional": [],
}, },
"image-block": {
"required": ["src", "alt"],
"optional": ["caption", "layout"],
},
"details-block": {
"required": ["summary_text", "detail_content"],
"optional": ["label"],
},
} }
def _load_catalog() -> str:
"""catalog.yaml이 있으면 로드하여 프롬프트용 텍스트 반환. 없으면 기본 블록 목록."""
catalog_path = Path(__file__).parent.parent / "templates" / "catalog.yaml"
if catalog_path.exists():
return catalog_path.read_text(encoding="utf-8")
# fallback: 기본 블록 목록
return """사용 가능한 블록:
- quote-block: 좌측 컬러 라인 + 인용 텍스트. 문제 제기, 핵심 주장할 때.
- card-grid: 2~4열 카드. 용어 정의, 개념 나열할 때.
- comparison: 2단 병렬. A vs B 비교할 때.
- comparison-table: 다항목 비교 테이블. 행/열 많을 때.
- relationship: 벤 다이어그램. 포함/상위-하위 관계할 때.
- process: 단계 흐름. 절차, 워크플로우할 때.
- conclusion-bar: 하단 결론 바. 핵심 한 줄.
- image-block: 이미지 + 캡션. full(전체너비)/side(텍스트옆)/thumb(썸네일) 3변형.
- details-block: 자세히보기. 요약 표면 + 펼치면 상세."""
DIRECTOR_PROMPT = """당신은 디자인 팀장이다. 실장이 분석한 꼭지 목록을 받아 레이아웃을 설계한다.
## 역할
- 각 꼭지에 적합한 블록을 매핑한다
- 전체 공간을 배분하고 겹침을 방지한다
- 각 블록의 글자 수 가이드를 결정한다
- **텍스트는 절대 정리하지 않는다** (텍스트 편집자가 별도로 한다)
## {catalog}
## 이미지 처리 규칙
- 원본 이미지를 그대로 사용한다 (crop 안 함, 크기만 조절)
- 가로형 이미지(비율 > 1.2) → 전체 너비(image-full)
- 세로형 이미지(비율 < 0.8) → 텍스트 옆(image-side)
- 텍스트 포함 도표 → 너무 작게 축소하면 안 됨
## 표 처리 규칙
- 표는 표로 유지한다 (다른 형태로 전환하지 않음)
- 공간에 안 들어가면 → 요약 요청 또는 페이지 분리
## 자세히보기 규칙
- 너무 구체적/세부적인 내용은 details-block으로 설계
- 슬라이드 표면: 요약만, 펼치면: 전체 상세
## 공간 배분 규칙
- CSS grid-template-areas 형식으로 배치
- 영역명: header, left, right, center, main, footer 등
- 꼭지끼리 겹치지 않도록 설계
- 각 블록에 대략적 크기 감(small/medium/large) 제시
## 글자 수 가이드 규칙
- 블록의 공간에 따라 대략적 글자 수 가이드를 제시
- 이것은 하드코딩 기준이 아니라 참고 가이드
- 텍스트 편집자가 의미를 우선하여 가이드와 다를 수 있음
## 출력 형식 (반드시 JSON만. 설명 없이.)
```json
{{
"pages": [
{{
"grid_areas": "'header header' 'left right' 'footer footer'",
"grid_columns": "1fr 1fr",
"grid_rows": "auto 1fr auto",
"blocks": [
{{
"area": "header",
"type": "quote-block",
"topic_id": 1,
"reason": "문제 제기 꼭지",
"size": "small",
"char_guide": {{"quote_text": 80, "source": 30}}
}}
]
}}
]
}}
```"""
async def create_layout_concept( async def create_layout_concept(
content: str, content: str,
classification: dict[str, Any], analysis: dict[str, Any],
) -> dict[str, Any]: ) -> dict[str, Any]:
"""디자인 팀장이 레이아웃 컨셉을 결정한다. """2단계: 디자인 팀장이 레이아웃 컨셉을 설계한다.
텍스트는 채우지 않는다. 블록 배치, 페이지 수, 슬롯 목록만 반환.
Args: Args:
content: 원본 텍스트 콘텐츠 content: 원본 텍스트 (분량 참고용)
classification: Opus의 분류 결과 analysis: 1단계 실장의 꼭지 분석 결과
Returns: Returns:
레이아웃 컨셉: 레이아웃 컨셉:
{ {"title": "...", "pages": [{"grid_areas": "...", "blocks": [...]}]}
"pages": [
{
"grid_areas": "...",
"grid_columns": "...",
"grid_rows": "...",
"blocks": [{"area": "header", "type": "quote-block", "reason": "..."}]
}
],
"title": "슬라이드 제목"
}
""" """
client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key) client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
# 기존 분류에서 블록 목록 추출 catalog_text = _load_catalog()
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" topics_summary = []
"## 역할\n" for t in analysis.get("topics", []):
"- 블록 배치와 페이지 수만 결정한다\n" line = (
"- 텍스트 내용은 절대 정리하지 않는다 (텍스트 편집자가 별도로 한다)\n\n" f"꼭지 {t['id']}: {t['title']} "
"## 규칙\n" f"[{t.get('layer', '?')}, 강조:{t.get('emphasis', False)}, "
"- 1페이지에 4~5파트가 적절하다\n" f"방향:{t.get('direction', '?')}, 유형:{t.get('content_type', 'text')}]"
"- 6파트 이상이면 2페이지로 나눈다\n" )
"- 핵심 파트를 억지로 줄이지 않는다\n" if t.get("image_info"):
"- CSS grid-template-areas 형식으로 배치를 지정한다\n" line += f" 이미지:{t['image_info']}"
"- JSON 형식으로만 응답한다\n\n" if t.get("table_info"):
"## 사용 가능한 블록 타입\n" line += f" 표:{t['table_info']}"
"comparison, card-grid, relationship, process, quote-block, conclusion-bar, comparison-table\n\n" if t.get("detail_target"):
"## 출력 형식\n" line += " → 자세히보기 대상"
'{"title": "제목", "pages": [{"grid_areas": "...", "grid_columns": "...", "grid_rows": "...", ' topics_summary.append(line)
'"blocks": [{"area": "...", "type": "...", "reason": "..."}]}]}'
) system = DIRECTOR_PROMPT.replace("{catalog}", catalog_text)
user_prompt = ( user_prompt = (
f"## Opus 실장 결과\n" f"## 실장 분 결과\n"
f"제목: {classification.get('title', '')}\n" f"제목: {analysis.get('title', '')}\n"
f"블록 목록:\n" + "\n".join(block_summary) + f"페이지 수: {analysis.get('total_pages', 1)}\n"
f"\n\n## 원본 콘텐츠 (분량 참고용)\n{content[:2000]}\n\n" f"꼭지 목록:\n" + "\n".join(topics_summary) +
f"\n\n## 원본 콘텐츠 (분량 참고)\n{content[:2000]}\n\n"
f"## 요청\n" f"## 요청\n"
f"블록을 몇 페이지에 어떻게 배치할지 결정해줘. " f"꼭지를 어떤 블록으로, 어디에, 몇 페이지 배치할지 설계해줘.\n"
f"텍스트는 채우지 마. 배치 구조만 JSON으로 반환해." f"텍스트는 채우지 마. 구조만 JSON으로."
) )
try: try:
response = await client.messages.create( response = await client.messages.create(
model="claude-sonnet-4-20250514", model="claude-sonnet-4-20250514",
max_tokens=2048, max_tokens=2048,
system=system_prompt, system=system,
messages=[{"role": "user", "content": user_prompt}], messages=[{"role": "user", "content": user_prompt}],
) )
@@ -128,29 +198,45 @@ async def create_layout_concept(
if concept and "pages" in concept: if concept and "pages" in concept:
total_blocks = sum(len(p.get("blocks", [])) for p in concept["pages"]) total_blocks = sum(len(p.get("blocks", [])) for p in concept["pages"])
logger.info( logger.info(
f"레이아웃 컨셉 완료: {len(concept['pages'])}페이지, " f"레이아웃 설계 완료: {len(concept['pages'])}페이지, "
f"{total_blocks}개 블록" f"{total_blocks}개 블록"
) )
return concept return {
"title": analysis.get("title", "슬라이드"),
**concept,
}
else: else:
logger.warning("레이아웃 컨셉 파싱 실패. 기존 분류를 1페이지로 사용.") logger.warning("레이아웃 설계 파싱 실패. fallback 사용.")
except Exception as e: except Exception as e:
logger.error(f"디자인 팀장 호출 실패: {e}", exc_info=True) logger.error(f"디자인 팀장 호출 실패: {e}", exc_info=True)
# fallback: 기존 분류를 1페이지로 감싸기 # fallback
return _fallback_single_page(classification) return _fallback_layout(analysis)
def _fallback_single_page(classification: dict[str, Any]) -> dict[str, Any]: def _fallback_layout(analysis: dict[str, Any]) -> dict[str, Any]:
"""분류 결과를 1페이지 컨셉으로 변환 (fallback).""" """팀장 실패 시 기본 레이아웃."""
blocks = []
areas = ["header", "main", "footer"]
for i, topic in enumerate(analysis.get("topics", [])[:3]):
area = areas[min(i, len(areas) - 1)]
blocks.append({
"area": area,
"type": "card-grid",
"topic_id": topic.get("id", i + 1),
"reason": topic.get("title", ""),
"size": "medium",
"char_guide": {"title": 20, "description": 100},
})
return { return {
"title": classification.get("title", "슬라이드"), "title": analysis.get("title", "슬라이드"),
"pages": [{ "pages": [{
"grid_areas": classification.get("grid_areas", "'header' 'main' 'footer'"), "grid_areas": "'header' 'main' 'footer'",
"grid_columns": classification.get("grid_columns", "1fr"), "grid_columns": "1fr",
"grid_rows": classification.get("grid_rows", "auto 1fr auto"), "grid_rows": "auto 1fr auto",
"blocks": classification.get("blocks", []), "blocks": blocks,
}], }],
} }
@@ -158,9 +244,9 @@ def _fallback_single_page(classification: dict[str, Any]) -> dict[str, Any]:
def _parse_json(text: str) -> dict[str, Any] | None: def _parse_json(text: str) -> dict[str, Any] | None:
"""텍스트에서 JSON을 추출한다.""" """텍스트에서 JSON을 추출한다."""
patterns = [ patterns = [
r'```json\s*(.*?)```', r"```json\s*(.*?)```",
r'```\s*(.*?)```', r"```\s*(.*?)```",
r'(\{.*\})', r"(\{.*\})",
] ]
for pattern in patterns: for pattern in patterns:
match = re.search(pattern, text, re.DOTALL) match = re.search(pattern, text, re.DOTALL)

View File

@@ -1,8 +1,7 @@
"""DA-12: Kei API 연동 + Opus 직접 분류. """DA-12: 1단계 — Kei 실장 (꼭지 추출 + 분석).
1차: Opus API를 직접 호출하여 콘텐츠 유형을 분한다 (안정적). 본문에서 핵심 꼭지를 추출하고, 각 꼭지의 레이어/강조/배치 방향을 분한다.
2차: Kei API 연동은 향후 RAG 통합 시 활용. 이미지/표/상세 콘텐츠도 판단한다.
Opus 실패 시: 수동 분류 fallback.
""" """
from __future__ import annotations from __future__ import annotations
@@ -17,45 +16,56 @@ from src.config import settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
CLASSIFICATION_PROMPT = """당신은 콘텐츠를 분석하여 슬라이드 레이아웃을 결정하는 실장이다. 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 - 본문에서 2~5개의 핵심 꼭지(파트)를 추출한다
- 1페이지 적정 꼭지 수: 5개
- 꼭지가 5개를 넘고 중요도가 동등하면 → 2페이지로 분리 (의미 기반 분할)
- 5개인데 내용이 많으면 → 세부 내용은 "자세히보기" 대상으로 표시
## 규칙 ## 각 꼭지 분석 항목
- 콘텐츠를 분석하여 각 덩어리의 유형을 판단한다 1. **레이어 수준**: 도입(문제 제기, 배경) / 핵심(핵심 내용, 정의) / 보조(사례, 근거) / 결론(요약, 핵심 메시지)
- 한 슬라이드에 블록 4~6개가 적절하다 2. **강조**: 눈에 띄게 해야 하는 꼭지 표시 (true/false)
- 정보 계층: 위→아래 (문제 제기 → 분석 → 결론) 3. **배치 방향**: 세로로 긴 내용(vertical) / 가로로 나열(horizontal) / 유연(flexible)
- 반드시 JSON으로만 응답한다. 설명 없이 JSON만. 4. **콘텐츠 유형**: text(텍스트) / image(이미지) / table(표) / mixed(혼합)
5. **이미지 정보** (이미지가 있는 경우):
- 핵심인지 보조인지 (core/supplementary)
- 텍스트 포함 여부 (도표/차트는 true)
6. **표 정보** (표가 있는 경우):
- 대략적 행/열 수
- 전체 표시 가능한지 판단
7. **자세히보기 대상**: 너무 구체적/세부적인 내용은 detail_target: true
## 출력 형식 ## 출력 형식 (반드시 JSON만. 설명 없이.)
```json ```json
{ {
"title": "슬라이드 제목", "title": "슬라이드 제목",
"grid_areas": "'header header' 'left right' 'footer footer'", "total_pages": 1,
"grid_columns": "1fr 1fr", "topics": [
"grid_rows": "auto 1fr auto", {
"blocks": [ "id": 1,
{"area": "header", "type": "quote-block", "reason": "문제"}, "title": "꼭지",
{"area": "left", "type": "comparison", "reason": "정책 비교"}, "summary": "꼭지 내용 요약 (1~2줄)",
{"area": "right", "type": "card-grid", "reason": "용어 정의 3개"}, "layer": "intro|core|supporting|conclusion",
{"area": "footer", "type": "conclusion-bar", "reason": "핵심 결론"} "emphasis": true,
"direction": "vertical|horizontal|flexible",
"content_type": "text|image|table|mixed",
"image_info": {"role": "core|supplementary", "has_text": true},
"table_info": {"rows": 5, "cols": 3, "fits_page": true},
"detail_target": false,
"page": 1
}
] ]
} }
```""" ```"""
async def classify_content(content: str) -> dict[str, Any] | None: async def classify_content(content: str) -> dict[str, Any] | None:
"""Opus API를 직접 호출하여 콘텐츠를한다. """1단계: 본문에서 꼭지를 추출하고한다.
Args: Args:
content: 원본 텍스트 콘텐츠 content: 원본 텍스트 콘텐츠
@@ -75,34 +85,38 @@ async def classify_content(content: str) -> dict[str, Any] | None:
max_tokens=2048, max_tokens=2048,
system=CLASSIFICATION_PROMPT, system=CLASSIFICATION_PROMPT,
messages=[ messages=[
{"role": "user", "content": f"다음 콘텐츠의 레이아웃을 결정해줘:\n\n{content}"} {
"role": "user",
"content": f"다음 콘텐츠를 분석하여 꼭지를 추출하고 구조를 설계해줘:\n\n{content}",
}
], ],
) )
result_text = response.content[0].text result_text = response.content[0].text
layout = _parse_layout_json(result_text) analysis = _parse_json(result_text)
if layout and "blocks" in layout: if analysis and "topics" in analysis:
logger.info( logger.info(
f"콘텐츠 분류 완료: {layout.get('title', 'untitled')}, " f"꼭지 추출 완료: {analysis.get('title', 'untitled')}, "
f"{len(layout['blocks'])}블록" f"{len(analysis['topics'])}꼭지, "
f"{analysis.get('total_pages', 1)}페이지"
) )
return layout return analysis
else: else:
logger.warning(f"분류 JSON 파싱 실패. 응답: {result_text[:200]}") logger.warning(f"분류 JSON 파싱 실패. 응답: {result_text[:200]}")
return None return None
except Exception as e: except Exception as e:
logger.warning(f"Opus 분류 호출 실패: {e}") logger.warning(f"실장 분류 호출 실패: {e}")
return None return None
def _parse_layout_json(text: str) -> dict[str, Any] | None: def _parse_json(text: str) -> dict[str, Any] | None:
"""텍스트에서 레이아웃 JSON을 추출한다.""" """텍스트에서 JSON을 추출한다."""
patterns = [ patterns = [
r'```json\s*(.*?)```', r"```json\s*(.*?)```",
r'```\s*(.*?)```', r"```\s*(.*?)```",
r'(\{.*\})', r"(\{.*\})",
] ]
for pattern in patterns: for pattern in patterns:
match = re.search(pattern, text, re.DOTALL) match = re.search(pattern, text, re.DOTALL)
@@ -115,27 +129,21 @@ def _parse_layout_json(text: str) -> dict[str, Any] | None:
def manual_classify(content: str) -> dict[str, Any]: def manual_classify(content: str) -> dict[str, Any]:
"""Opus 실패 시 기본 레이아웃을 반환하는 fallback.""" """실장 분류 실패 시 기본 구조를 반환하는 fallback."""
return { return {
"title": "슬라이드", "title": "슬라이드",
"grid_areas": "'header' 'main' 'footer'", "total_pages": 1,
"grid_columns": "1fr", "topics": [
"grid_rows": "auto 1fr auto",
"blocks": [
{ {
"area": "header", "id": 1,
"type": "quote-block", "title": "핵심 내용",
"reason": "기본 인용 블록", "summary": content[:100],
}, "layer": "core",
{ "emphasis": False,
"area": "main", "direction": "flexible",
"type": "card-grid", "content_type": "text",
"reason": "기본 카드 그리드", "detail_target": False,
}, "page": 1,
{
"area": "footer",
"type": "conclusion-bar",
"reason": "기본 결론",
}, },
], ],
} }

View File

@@ -1,16 +1,24 @@
"""DA-14: 전체 파이프라인 (3단계). """DA-14: 전체 파이프라인 (5단계).
콘텐츠 입력 → Opus 분류 → 디자인 팀장 컨셉 → 텍스트 편집자 정리 → 렌더러 조립 → HTML 출력. 1. Kei 실장: 꼭지 추출 + 분석
2. 디자인 팀장: 레이아웃 설계
3. 텍스트 편집자: 텍스트 정리
4. 디자인 실무자: HTML 조립
5. 디자인 팀장: 전체 재검토
""" """
from __future__ import annotations from __future__ import annotations
import json
import logging import logging
from typing import Any, AsyncIterator from typing import Any, AsyncIterator
import anthropic
from src.kei_client import classify_content, manual_classify from src.kei_client import classify_content, manual_classify
from src.design_director import create_layout_concept, _fallback_single_page from src.design_director import create_layout_concept
from src.content_editor import fill_content from src.content_editor import fill_content
from src.renderer import render_slide from src.renderer import render_slide
from src.config import settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -19,53 +27,183 @@ async def generate_slide(
content: str, content: str,
manual_layout: dict[str, Any] | None = None, manual_layout: dict[str, Any] | None = None,
) -> AsyncIterator[dict[str, str]]: ) -> AsyncIterator[dict[str, str]]:
"""콘텐츠를 슬라이드 HTML로 변환하는 전체 파이프라인. """콘텐츠를 슬라이드 HTML로 변환하는 5단계 파이프라인.
Args:
content: 원본 텍스트 콘텐츠
manual_layout: 수동 레이아웃 명세 (Opus 대신 사용)
Yields: Yields:
SSE 이벤트: SSE 이벤트: progress / result / error
{"event": "progress", "data": "단계 설명"}
{"event": "result", "data": "완성 HTML"}
{"event": "error", "data": "에러 메시지"}
""" """
try: try:
# 1단계: Kei 실장 (Opus) — 콘텐츠 # 1단계: Kei 실장 — 꼭지 추출 +
yield {"event": "progress", "data": "1/4 Kei 실장이 콘텐츠를 분석 중..."} yield {"event": "progress", "data": "1/5 Kei 실장이 꼭지를 추출 중..."}
if manual_layout: if manual_layout:
classification = manual_layout analysis = manual_layout
else: else:
classification = await classify_content(content) analysis = await classify_content(content)
if classification is None: if analysis is None:
classification = manual_classify(content) analysis = manual_classify(content)
logger.info(f"분류 완료: {len(classification.get('blocks', []))}개 블록") topic_count = len(analysis.get("topics", []))
page_count = analysis.get("total_pages", 1)
logger.info(f"1단계 완료: {topic_count}개 꼭지, {page_count}페이지")
# 2단계: 디자인 팀장 — 레이아웃 컨셉 # 2단계: 디자인 팀장 — 레이아웃 설계
yield {"event": "progress", "data": "2/4 디자인 팀장이 레이아웃을 설계 중..."} yield {"event": "progress", "data": "2/5 디자인 팀장이 레이아웃을 설계 중..."}
layout_concept = await create_layout_concept(content, classification) layout_concept = await create_layout_concept(content, analysis)
total_pages = len(layout_concept.get("pages", [])) total_blocks = sum(
total_blocks = sum(len(p.get("blocks", [])) for p in layout_concept.get("pages", [])) len(p.get("blocks", [])) for p in layout_concept.get("pages", [])
logger.info(f"레이아웃 컨셉: {total_pages}페이지, {total_blocks}개 블록") )
logger.info(
f"2단계 완료: {len(layout_concept.get('pages', []))}페이지, "
f"{total_blocks}개 블록"
)
# 3단계: 텍스트 편집자 (Kei 역할) — 슬롯 텍스트 정리 # 3단계: 텍스트 편집자 텍스트 정리
yield {"event": "progress", "data": "3/4 텍스트 편집자가 핵심을 정리 중..."} yield {"event": "progress", "data": "3/5 텍스트 편집자가 핵심을 정리 중..."}
layout_concept = await fill_content(content, layout_concept) layout_concept = await fill_content(content, layout_concept, analysis)
logger.info("3단계 완료: 텍스트 정리")
# 4단계: 실무자 — HTML 렌더링 # 4단계: 디자인 실무자 — HTML 조립
yield {"event": "progress", "data": "4/4 슬라이드를 조립 중..."} yield {"event": "progress", "data": "4/5 디자인 실무자가 슬라이드를 조립 중..."}
html = render_slide(layout_concept) html = render_slide(layout_concept)
logger.info("4단계 완료: HTML 조립")
# 5단계: 디자인 팀장 — 전체 재검토
yield {"event": "progress", "data": "5/5 디자인 팀장이 전체 균형을 검토 중..."}
review_result = await _review_balance(html, layout_concept, content)
if review_result and review_result.get("needs_adjustment"):
logger.info(
f"5단계: 조정 필요 — {review_result.get('issues', [])}"
)
# 조정 지시에 따라 텍스트 재편집 또는 레이아웃 재조정
layout_concept = await _apply_adjustments(
layout_concept, review_result, content
)
html = render_slide(layout_concept)
logger.info("5단계 완료: 2차 조정 반영")
else:
logger.info("5단계 완료: 조정 불필요")
yield {"event": "result", "data": html} yield {"event": "result", "data": html}
logger.info(f"슬라이드 생성 완료: {total_pages}페이지") logger.info(f"슬라이드 생성 완료: {len(layout_concept.get('pages', []))}페이지")
except Exception as e: except Exception as e:
logger.exception(f"파이프라인 오류: {e}") logger.exception(f"파이프라인 오류: {e}")
yield {"event": "error", "data": str(e)} yield {"event": "error", "data": str(e)}
async def _review_balance(
html: str,
layout_concept: dict[str, Any],
content: str,
) -> dict[str, Any] | None:
"""5단계: 디자인 팀장이 1차 조립 결과를 재검토한다.
HTML 코드 기반으로 구조적 점검:
- 빈 블록 감지
- 블록 간 채움 비율 불균형
- 이미지/표 크기 적절성
"""
try:
client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
# 블록별 텍스트 양 요약
block_summary = []
for page in layout_concept.get("pages", []):
for block in page.get("blocks", []):
data = block.get("data", {})
text_len = len(json.dumps(data, ensure_ascii=False))
block_summary.append(
f" {block.get('area')}/{block.get('type')}: "
f"데이터 {text_len}"
)
system = (
"당신은 디자인 팀장이다. 1차 조립 결과를 검토하여 균형을 점검한다.\n\n"
"## 점검 항목\n"
"1. 빈 블록: 데이터가 없거나 극히 적은 블록\n"
"2. 채움 불균형: 한 블록은 빽빽하고 다른 블록은 비어있음\n"
"3. 이미지/표: 너무 작거나 큰 것은 없는지\n"
"4. 전체 정보량: 한 페이지에 너무 많거나 적은지\n\n"
"## 출력 형식 (JSON만)\n"
'{"needs_adjustment": true/false, '
'"issues": ["이슈1", "이슈2"], '
'"adjustments": [{"block_area": "...", "action": "expand|shrink|rewrite", "detail": "..."}]}'
)
user_prompt = (
f"## 블록별 데이터 양\n" + "\n".join(block_summary) +
f"\n\n## 레이아웃 구조\n"
f"페이지 수: {len(layout_concept.get('pages', []))}\n"
f"총 블록 수: {sum(len(p.get('blocks', [])) for p in layout_concept.get('pages', []))}\n\n"
f"조정이 필요한가? JSON으로 답해."
)
response = await client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=1024,
system=system,
messages=[{"role": "user", "content": user_prompt}],
)
result_text = response.content[0].text
return _parse_json(result_text)
except Exception as e:
logger.warning(f"재검토 실패: {e}")
return None
async def _apply_adjustments(
layout_concept: dict[str, Any],
review: dict[str, Any],
content: str,
) -> dict[str, Any]:
"""재검토 결과에 따라 텍스트를 재편집한다."""
adjustments = review.get("adjustments", [])
if not adjustments:
return layout_concept
# 조정이 필요한 블록만 재편집
for adj in adjustments:
area = adj.get("block_area", "")
action = adj.get("action", "")
detail = adj.get("detail", "")
for page in layout_concept.get("pages", []):
for block in page.get("blocks", []):
if block.get("area") == area and action in ("expand", "rewrite"):
# 해당 블록의 char_guide를 조정하여 재편집 유도
if action == "expand":
for key in block.get("char_guide", {}):
block["char_guide"][key] = int(
block["char_guide"][key] * 1.5
)
logger.info(f"조정: {area}{action} ({detail})")
# 조정된 가이드로 재편집
layout_concept = await fill_content(content, layout_concept)
return layout_concept
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