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:
15
.mcp.json
Normal file
15
.mcp.json
Normal 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
395
CLAUDE.md
@@ -2,70 +2,175 @@
|
||||
|
||||
## 프로젝트 목적
|
||||
|
||||
텍스트 콘텐츠를 **1페이지 가로 슬라이드**로 시각 구조화하는 독립 에이전트.
|
||||
텍스트/MDX 콘텐츠를 **가로 슬라이드(1페이지 또는 다중 페이지)**로 시각 구조화하는 독립 에이전트.
|
||||
콘텐츠의 의미를 분석하여 적합한 레이아웃 블록을 선택하고, 핵심만 추출하여 깔끔한 HTML/CSS로 렌더링한다.
|
||||
|
||||
**핵심 원칙:** 전체 페이지를 하나의 고정 템플릿으로 찍어내는 것이 아니라, 콘텐츠를 분석 → 각 덩어리별로 적합한 레이아웃 블록 선택 → 조합하여 배치.
|
||||
**핵심 원칙:**
|
||||
- 전체 페이지를 하나의 고정 템플릿으로 찍어내는 것이 아니라, 콘텐츠를 분석 → 각 덩어리별로 적합한 레이아웃 블록 선택 → 조합하여 배치
|
||||
- 기획자(편집자)가 정리한 텍스트가 기준. **디자인이 텍스트에 맞춘다** (텍스트가 디자인에 맞추는 것이 아님)
|
||||
- **모든 판단은 실장/팀장/편집자의 사고. 하드코딩 없음**
|
||||
|
||||
---
|
||||
|
||||
## 아키텍처
|
||||
## 아키텍처 (5단계 파이프라인)
|
||||
|
||||
```
|
||||
Kei (실장) — Kei Persona API 호출
|
||||
"이 콘텐츠는 비교+정의+관계도 구조다. 이렇게 배치해라."
|
||||
[1단계] Kei 실장 (Sonnet) — AI 사고
|
||||
꼭지 추출 → 레이어 수준 → 강조 판단 → 배치 방향
|
||||
↓
|
||||
디자인 팀장 (Sonnet)
|
||||
"비교는 2단, 정의는 카드 3열, 관계도는 벤. 핵심만 남기고 나머지 버려."
|
||||
[2단계] 디자인 팀장 (Sonnet) — AI 사고
|
||||
블록 매핑 + 공간 배분 + 글자 수 가이드 → 편집자에게 전달
|
||||
↓
|
||||
실행자 (CSS Grid 렌더러)
|
||||
"팀장이 정한 대로 CSS Grid로 조립."
|
||||
[3단계] Kei 텍스트 편집자 (Sonnet) — AI 사고
|
||||
글자 수 가이드 참고하되 내용 의미 우선. 도메인 용어 보존하며 편집
|
||||
↓
|
||||
[4단계] 디자인 실무자 (Sonnet + Jinja2 + CSS) — AI + 코드
|
||||
편집자가 정리한 텍스트에 맞게 디자인 조정 + HTML 조립
|
||||
↓
|
||||
[5단계] 디자인 팀장 (Sonnet) — AI 사고
|
||||
전체 균형 재검토 → 공간 재배분 → 2차 조정 지시
|
||||
```
|
||||
|
||||
### 역할 분리
|
||||
|
||||
| 역할 | 담당 | 하는 일 | 하지 않는 일 |
|
||||
|------|------|---------|------------|
|
||||
| Kei (실장) | Opus via Kei API | 콘텐츠 의미 분석, 유형 분류, 배치 방향 결정 | 디자인, CSS 작성 |
|
||||
| 디자인 팀장 | Sonnet | 블록 타입 선택, 콘텐츠 선별(70% 버림), 슬롯 채우기, 세부 기준 수립 | 콘텐츠 의미 판단 |
|
||||
| 실행자 | CSS Grid 렌더러 | 확정적 HTML/CSS 생성, 디자인 토큰 적용 | 판단, 선택 |
|
||||
| 역할 | 담당 | 방식 | 하는 일 | 하지 않는 일 |
|
||||
|------|------|------|---------|------------|
|
||||
| Kei 실장 | Sonnet | AI | 꼭지 추출, 레이어 판단, 강조 판단, 배치 방향, 이미지/표/상세 판단 | 디자인, 텍스트 편집 |
|
||||
| 디자인 팀장 | Sonnet | AI | catalog에서 블록 선택, 공간 배분, 겹침 방지, 글자 수 가이드, 전체 재검토 | 텍스트 정리, 콘텐츠 의미 판단 |
|
||||
| 텍스트 편집자 | Sonnet | AI | 도메인 용어 보존하며 편집, 출처 보존, 표 내용 편집 | 레이아웃 결정, 디자인 판단 |
|
||||
| 디자인 실무자 | Sonnet + 코드 | AI + 코드 | 텍스트에 맞게 디자인 조정, HTML/CSS 조립, 이미지 크기 조정, 표 스케일링 | 콘텐츠 의미 판단 |
|
||||
|
||||
---
|
||||
|
||||
## 핵심 프로세스
|
||||
|
||||
```
|
||||
사용자 콘텐츠 입력 (텍스트 붙여넣기 또는 파일 업로드)
|
||||
사용자 콘텐츠 입력 (텍스트/MDX 붙여넣기 또는 파일 업로드)
|
||||
↓
|
||||
[1단계] Kei 실장(Opus) — 콘텐츠 유형 분류
|
||||
→ "이건 비교(A vs B) + 정의(3개 용어) + 관계도(상위/하위)"
|
||||
→ 적합한 블록 조합 결정
|
||||
[1단계] Kei 실장 — 꼭지 추출 + 분석
|
||||
|
||||
꼭지 추출:
|
||||
- 본문에서 핵심 꼭지 추출 (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 생성
|
||||
→ 블록 타입에 맞는 CSS 템플릿 적용
|
||||
→ 디자인 토큰 (색상, 여백, 폰트 크기) 적용
|
||||
→ 다중 페이지 시 page-break 처리
|
||||
[4단계] 디자인 실무자 — 디자인 조정 + HTML 조립
|
||||
|
||||
텍스트 맞춤:
|
||||
- 편집자가 정리한 텍스트 양에 맞게 디자인 조정
|
||||
- 텍스트가 가이드보다 길면 → 폰트/여백/박스를 조정 (텍스트를 자르지 않음)
|
||||
- 빈 공간 방치 안 함 (박스 줄이거나 공간 활용)
|
||||
|
||||
이미지 처리:
|
||||
- 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`)
|
||||
- 가로 슬라이드 비율: 16:9 (1280×720 또는 1920×1080)
|
||||
- 최대 블록 수: 1페이지에 4~6개
|
||||
- 가로 슬라이드 비율: 16:9 (1280×720)
|
||||
- 1페이지 적정 꼭지 수: 5개
|
||||
- 정보 계층: 위 → 아래 (문제 제기 → 분석 → 결론)
|
||||
- 여백: 블록 간 최소 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 (해야 하는 것)
|
||||
@@ -171,16 +337,15 @@ Opus가 콘텐츠를 분석하여 아래 유형으로 분류한다.
|
||||
- 폰트 크기 체계를 일관되게 유지 (제목/소제목/본문/캡션 4단계)
|
||||
- 흑백 기조 + 포인트 컬러 최소 사용
|
||||
- 정보 계층을 시각적으로 명확히 표현
|
||||
- 한 슬라이드에 메시지는 1개
|
||||
|
||||
### DON'T (하지 않는 것)
|
||||
- 그라데이션 배경 금지
|
||||
- CSS 애니메이션/트랜지션 금지
|
||||
- 호버 효과 금지
|
||||
- 그림자(box-shadow) 최소화 (1개 레벨만)
|
||||
- 원본 콘텐츠를 전부 넣으려 하지 않는다 (70% 버려라)
|
||||
- 다크 테마 금지 (요청하지 않는 한)
|
||||
- 둥근 모서리 과다 사용 금지 (border-radius 최대 8px)
|
||||
- 텍스트를 자르지 않는다 (디자인이 텍스트에 맞춘다)
|
||||
|
||||
---
|
||||
|
||||
@@ -189,98 +354,84 @@ Opus가 콘텐츠를 분석하여 아래 유형으로 분류한다.
|
||||
```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-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; /* 캡션/출처 */
|
||||
--font-title: 2rem;
|
||||
--font-subtitle: 1.25rem;
|
||||
--font-body: 0.95rem;
|
||||
--font-caption: 0.8rem;
|
||||
|
||||
/* 여백 */
|
||||
--spacing-page: 40px; /* 페이지 패딩 */
|
||||
--spacing-block: 20px; /* 블록 간 간격 */
|
||||
--spacing-inner: 16px; /* 블록 내부 패딩 */
|
||||
--spacing-page: 40px;
|
||||
--spacing-block: 20px;
|
||||
--spacing-inner: 16px;
|
||||
|
||||
/* 기타 */
|
||||
--radius: 6px; /* 둥근 모서리 */
|
||||
--border-width: 1px; /* 테두리 두께 */
|
||||
--accent-border: 3px; /* 강조 테두리 */
|
||||
--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 불필요) |
|
||||
| 1단계 꼭지 추출 | Anthropic API (Sonnet) | 꼭지 추출 + 레이어 + 강조 + 배치 + 이미지/표/상세 판단 |
|
||||
| 2단계 레이아웃 설계 | Anthropic API (Sonnet) | 블록 매핑 + 공간 배분 + 글자 수 가이드 |
|
||||
| 3단계 텍스트 정리 | Anthropic API (Sonnet) | 의미 보존 편집 + 표 편집 + 자세히보기 작성 |
|
||||
| 4단계 디자인 조정 + 조립 | Anthropic API (Sonnet) + Jinja2/CSS | 텍스트에 맞게 디자인 조정 + HTML 생성 |
|
||||
| 5단계 재검토 | Anthropic API (Sonnet) | 균형 점검 + 2차 조정 |
|
||||
|
||||
### 독립 실행 가능
|
||||
- Kei API 없이도 2-3단계만으로 동작 가능 (사용자가 직접 유형 선택)
|
||||
- Kei API 연결 시 1단계 자동화
|
||||
- Kei API 없이 Anthropic API 직접 호출로 동작
|
||||
- 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와 동일 스택 |
|
||||
| 렌더링 | CSS Grid + 디자인 토큰 | 순수 CSS, 프레임워크 없음 |
|
||||
| AI 콘텐츠 분석 | Kei API (Opus + Sonnet) | localhost:8000 |
|
||||
| 출력 | HTML 다운로드 | PDF 불필요 |
|
||||
| 서버 | FastAPI + uvicorn | 포트 8001 |
|
||||
| 템플릿 | Jinja2 | 블록 조합 |
|
||||
| 렌더링 | CSS Grid + 디자인 토큰 | 16:9 고정 |
|
||||
| 폰트 | 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) 둘 다
|
||||
```
|
||||
|
||||
독립적으로 만들어두면 어디에 붙이든 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이 사고한다)
|
||||
2. 디자인 판단을 하드코딩하지 않는다 (AI가 사고한다)
|
||||
3. 전체 페이지를 하나의 고정 템플릿으로 만들지 않는다 (블록 조합 방식)
|
||||
4. 콘텐츠를 전부 넣으려 하지 않는다 (핵심만 추출)
|
||||
5. 그라데이션, 애니메이션, 다크 테마를 기본으로 사용하지 않는다
|
||||
6. 교본 없이 자유 디자인을 하지 않는다 (교본 참조 필수)
|
||||
4. 텍스트를 자르지 않는다 (디자인이 텍스트에 맞춘다)
|
||||
5. 이미지를 crop하지 않는다 (크기만 조절)
|
||||
6. 그라데이션, 애니메이션, 다크 테마를 기본으로 사용하지 않는다
|
||||
|
||||
73
PLAN.md
73
PLAN.md
@@ -77,35 +77,57 @@
|
||||
|
||||
## Phase 3: AI 파이프라인 연결
|
||||
|
||||
### DA-12: Kei API 연동 — 콘텐츠 분류 (Opus)
|
||||
### DA-12: 1단계 — Kei 실장 (꼭지 추출 + 분석)
|
||||
- **파일:** 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
|
||||
- **완료 기준:** 테스트 콘텐츠 전송 → 유형 분류 JSON 반환
|
||||
- **완료 기준:** 꼭지 목록 + 레이어 + 강조 + 배치 + 이미지/표/상세 판단 JSON
|
||||
|
||||
### DA-13: 디자인 팀장 — 레이아웃 컨셉 (Sonnet)
|
||||
### DA-13: 2단계 — 디자인 팀장 (레이아웃 설계)
|
||||
- **파일:** src/design_director.py
|
||||
- **내용:** Anthropic API 직접 호출. Opus 분류 결과 + 원본 콘텐츠 → 레이아웃 컨셉만 결정. 텍스트 정리 안 함.
|
||||
- **출력:** 블록 배치 + 페이지 수 + 슬롯 목록 (텍스트 없이 구조만)
|
||||
- **기술:** Anthropic API (Sonnet), JSON 반환
|
||||
- **내용:** 블록 매핑 + 공간 배분 + 글자 수 가이드
|
||||
- 블록 매핑: catalog 메뉴판에서 각 꼭지 성격에 맞는 블록 선택
|
||||
- 이미지 배치: Pillow로 원본 크기 확인 → 가로/세로에 따라 영역 결정 (크기만 조절, crop 안 함)
|
||||
- 표 배치: 행×열 규모 보고 공간 판단 (안 되면 요약 요청 또는 페이지 분리)
|
||||
- 자세히보기: 상세 콘텐츠는 `<details>` 영역으로 설계
|
||||
- 공간 배분: 영역별 비율, 겹침 방지
|
||||
- 글자 수 가이드: 각 블록 공간에 맞는 대략적 글자 수 (하드코딩 아닌 판단)
|
||||
- 페이지 판단: 안 들어가면 2페이지 분리
|
||||
- **기술:** Anthropic API (Sonnet) + Pillow (이미지 크기)
|
||||
- **의존성:** DA-12
|
||||
- **완료 기준:** "이 파트는 카드로, 이건 비교로, 2페이지 필요" 수준의 컨셉 JSON 반환
|
||||
- **완료 기준:** 블록 배치 + 이미지/표 배치 + 글자 수 가이드 JSON
|
||||
|
||||
### DA-13b: 텍스트 편집자 — 슬롯 텍스트 정리 (Kei 역할)
|
||||
- **파일:** src/content_editor.py (신규)
|
||||
- **내용:** Anthropic API 직접 호출. 디자인 팀장의 레이아웃 컨셉 + 원본 콘텐츠 → 각 슬롯에 맞는 텍스트 편집. 도메인 지식 보존, 핵심 유지.
|
||||
- **역할:** 도메인 전문가로서 콘텐츠를 정리하는 편집자 (Kei persona 규칙 일부 적용)
|
||||
- **규칙:** 핵심 내용 유지, 개조식, 출처 보존, 슬롯 분량 준수, 내용 날조 금지
|
||||
- **기술:** Anthropic API (Sonnet), JSON 반환
|
||||
### DA-13b: 3단계 — Kei 텍스트 편집자 (텍스트 정리)
|
||||
- **파일:** src/content_editor.py
|
||||
- **내용:** 팀장의 글자 수 가이드 참고하되 내용 의미 우선
|
||||
- 전체 컨텍스트와 핵심 용어 유지
|
||||
- 세련된 표현으로 편집 (의미 > 글자 수)
|
||||
- 출처 보존, 개조식, 날조 금지
|
||||
- 표 내용 편집 (핵심 행/열 선택, 요약)
|
||||
- 자세히보기 대상: 요약 버전 + 상세 버전 둘 다 작성
|
||||
- **기술:** Anthropic API (Sonnet)
|
||||
- **의존성:** DA-13
|
||||
- **완료 기준:** 슬롯별 텍스트가 채워진 JSON 반환. 원본 핵심 내용 보존 확인.
|
||||
- **완료 기준:** 슬롯별 텍스트 JSON. 핵심 용어 보존. 자세히보기 포함.
|
||||
|
||||
### DA-14: 전체 파이프라인 연결 (3단계)
|
||||
- **파일:** src/pipeline.py
|
||||
- **내용:** 콘텐츠 입력 → Opus 분류 → 디자인 팀장 컨셉 → 텍스트 편집자 정리 → 렌더러 조립 → HTML 출력
|
||||
- **기술:** 순차 호출, 다중 페이지 지원
|
||||
### DA-14: 4단계 — 디자인 실무자 (디자인 조정 + HTML 조립) + 5단계 재검토
|
||||
- **파일:** src/pipeline.py, src/renderer.py
|
||||
- **내용:**
|
||||
- 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
|
||||
- **완료 기준:** 텍스트 입력 → 완성 슬라이드 HTML 출력 (엔드투엔드, 다중 페이지 포함)
|
||||
- **완료 기준:** 텍스트 입력 → 균형 잡힌 슬라이드 HTML (이미지/표/자세히보기 포함, 재검토 완료)
|
||||
|
||||
---
|
||||
|
||||
@@ -148,13 +170,14 @@
|
||||
## 의존 관계
|
||||
|
||||
```
|
||||
DA-1 → DA-2 → DA-12 → DA-13 ─┐
|
||||
├→ DA-14 → DA-15 → DA-16
|
||||
DA-3 → DA-4~DA-10 → DA-11 ────┘
|
||||
DA-1 → DA-2 → DA-12(실장) → DA-13(팀장) → DA-13b(편집자) ─┐
|
||||
├→ 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 필요.
|
||||
- Phase 1~2: AI 없이 진행 가능
|
||||
- Phase 3: Anthropic API 필요 (5단계 파이프라인)
|
||||
- 5단계 흐름: 실장 → 팀장 → 편집자 → 조립 → 재검토
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -37,10 +37,10 @@
|
||||
|
||||
| 태스크 | 상태 | 담당 | 시작 | 완료 | 메모 |
|
||||
|--------|------|------|------|------|------|
|
||||
| DA-12: Kei API 연동 (Opus) | done | - | - | - | DA-2 이후 |
|
||||
| DA-13: 디자인 팀장 — 레이아웃 컨셉만 | todo | - | - | - | 기존에서 텍스트 정리 제거. 컨셉만 반환 |
|
||||
| DA-13b: 텍스트 편집자 (Kei 역할) | todo | - | - | - | 신규. 도메인 전문가로 슬롯 텍스트 정리 |
|
||||
| DA-14: 전체 파이프라인 (3단계) | todo | - | - | - | 분류→컨셉→텍스트→렌더링. 다중 페이지 |
|
||||
| DA-12: 1단계 Kei 실장 (꼭지 추출+분석) | todo | - | - | - | 2~5개 꼭지 + 레이어 + 강조 + 이미지/표/상세 판단. 페이지 분리 |
|
||||
| DA-13: 2단계 디자인 팀장 (레이아웃 설계) | todo | - | - | - | 블록 매핑 + 이미지/표 배치 + 공간 배분 + 글자 수 가이드 |
|
||||
| DA-13b: 3단계 텍스트 편집자 (Kei 역할) | todo | - | - | - | 의미 우선 편집 + 표 편집 + 자세히보기(요약+상세) |
|
||||
| DA-14: 4단계 실무자(AI+코드) + 5단계 재검토 | todo | - | - | - | 디자인 조정 + HTML 조립 + 팀장 균형 재검토 |
|
||||
|
||||
## Phase 4: UI + 출력
|
||||
|
||||
|
||||
@@ -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 섹션** | ❌ | ✅ (배경+원형이미지+텍스트) | **신규** |
|
||||
| **CTA 버튼 바** | ❌ | ✅ (자세히보기 버튼) | **필요 시** |
|
||||
| **이미지 블록** | ❌ | ✅ (도표, 참고자료) | **신규** (3변형: full/side/thumb) |
|
||||
| **자세히보기 블록** | ❌ | ✅ (상세 콘텐츠 접기/펼치기) | **신규** (`<details>/<summary>`) |
|
||||
|
||||
---
|
||||
|
||||
@@ -66,19 +81,41 @@ 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 저장
|
||||
- **완료 기준:** 모든 자세히보기 프레임(8개)의 스크린샷 확보
|
||||
|
||||
#### 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 노드: 폰트, 크기, 색상, 내용
|
||||
- FRAME/GROUP: 레이아웃 방식 (auto-layout, constraints)
|
||||
- RECTANGLE: 배경색, 테두리, 둥근 모서리
|
||||
- INSTANCE: 재사용 컴포넌트 식별
|
||||
- TEXT 노드: fontFamily, fontSize, fontWeight, lineHeight, letterSpacing, color, 텍스트 내용
|
||||
- FRAME/GROUP: auto-layout (direction, gap, padding, alignItems, justifyContent), constraints
|
||||
- RECTANGLE: fills (solid/gradient/image), strokes, cornerRadius, effects
|
||||
- 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/` 폴더에 구조 문서
|
||||
- **주의:** Figma API rate limit 심함 — depth 깊은 요청은 30분 차단 가능. 얕게 요청 후 필요한 노드만 상세 조회
|
||||
|
||||
#### A-3: 디자인 패턴 분류 + 명명
|
||||
- **작업:** 추출된 시각 요소를 재사용 가능한 블록 단위로 분류
|
||||
@@ -90,20 +127,50 @@ Figma 디자인(바론컨설턴트 홈페이지 기획팀 공유)에서 재사
|
||||
|
||||
### Phase B: HTML/CSS 컴포넌트 제작
|
||||
|
||||
#### B-1: 신규 블록 템플릿 제작 (6~8종)
|
||||
- **파일:** `templates/blocks/{name}.html`
|
||||
#### B-1: 신규 블록 템플릿 제작 (8~10종)
|
||||
- **파일:** `templates/blocks/{name}/` 폴더별 정리 (변형별 파일 + preview.png)
|
||||
- **제작 순서 (우선순위):**
|
||||
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` — 아이콘 리스트 (아이콘+제목+설명, 기능 나열)
|
||||
1. `section-title/default.html` — 공통 헤더 (모든 슬라이드에서 사용)
|
||||
2. `example-card/2col.html` — 사례 카드 (출처+불릿, 정책 문서 인용)
|
||||
3. `image-block/full.html`, `side.html`, `thumb.html` — 이미지 블록 3변형
|
||||
- full: 전체 너비 (핵심 도표, 가로형)
|
||||
- side: 텍스트 옆 (보조 이미지, 세로형)
|
||||
- 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-*)`) 사용 (하드코딩 색상 금지)
|
||||
- Jinja2 슬롯 (`{{ variable }}`) 형식
|
||||
- `<style>` 태그를 블록 HTML 안에 포함 (자체 완결)
|
||||
- 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: 기존 블록 변형 추가
|
||||
- **대상:**
|
||||
@@ -268,10 +335,29 @@ layouts:
|
||||
| 리스크 | 설명 | 대응 |
|
||||
|--------|------|------|
|
||||
| **색상 불일치** | Figma는 파란 그라데이션 배경 사용, 디자인 토큰은 `#2563eb` 단색 | Figma 색상을 참고하되 토큰 체계 우선. 필요 시 토큰 추가 (`--color-accent-light` 등) |
|
||||
| **폰트 불일치** | Figma는 특정 폰트(웹폰트 아닐 수 있음), 토큰은 Pretendard | Pretendard 유지. Figma 폰트 크기 비율만 참고 |
|
||||
| **여백 불일치** | Figma 920px 프레임 vs 슬라이드 1280px | 비율 기반으로 변환 (920:1280 = 0.72배) |
|
||||
| **폰트 불일치** | Figma는 Noto Sans CJK KR 사용, 토큰은 Pretendard Variable | Pretendard 유지. Figma 폰트 크기 비율만 참고 |
|
||||
| **여백 불일치** | 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. 블록 개수 증가 → 디자인 팀장 혼란
|
||||
|
||||
@@ -296,10 +382,37 @@ layouts:
|
||||
|
||||
| 리스크 | 설명 | 대응 |
|
||||
|--------|------|------|
|
||||
| **CSS 미제공** | Figma API는 CSS를 직접 제공하지 않음. 스타일 속성(fill, fontSize 등)만 제공 | 스타일 속성에서 CSS 수동 변환. 복잡한 것은 스크린샷 보고 직접 작성 |
|
||||
| **이미지 에셋** | 벡터(VECTOR, ELLIPSE)는 PNG로 렌더링 가능하나 CSS 재현 필요 | 단순 도형은 CSS로 재현, 복잡한 것은 PNG export 후 img 태그 |
|
||||
| **CSS 미제공** | Figma REST API는 CSS를 직접 제공하지 않음. 스타일 속성만 제공 | **Framelink MCP 사용 시 자동 CSS 변환.** REST API만 사용 시 수동 변환 필요 |
|
||||
| **이미지 에셋** | 벡터(VECTOR, ELLIPSE)는 PNG로 렌더링 가능하나 CSS 재현 필요 | **Framelink MCP `download_figma_images`로 일괄 export.** 단순 도형은 CSS, 복잡한 것은 PNG |
|
||||
| **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) 연결 시 충돌
|
||||
|
||||
@@ -354,23 +467,69 @@ A-2 (노드 구조 분석) ──┘
|
||||
```
|
||||
docs/
|
||||
├── figma-screenshots/ # A-1: 각 프레임 PNG
|
||||
├── figma-analysis/ # A-2: 노드 구조 문서
|
||||
├── figma-analysis/ # A-2: 노드 구조 문서 + 디자인 토큰 매핑 테이블
|
||||
└── FIGMA-COMPONENT-EXTRACTION-PLAN.md # 이 파일
|
||||
|
||||
templates/
|
||||
├── catalog.yaml # C-1: 블록 카탈로그 (AI용 + 사람용)
|
||||
├── 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: 업데이트
|
||||
│ ├── comparison/ # 기존 (폴더 구조로 재편)
|
||||
│ │ ├── default.html
|
||||
│ │ └── preview.png
|
||||
│ ├── card-grid/ # 기존
|
||||
│ │ ├── default.html
|
||||
│ │ ├── icon.html # B-2: 변형
|
||||
│ │ └── preview.png
|
||||
│ ├── relationship/ # 기존
|
||||
│ ├── process/ # 기존
|
||||
│ ├── quote-block/ # 기존
|
||||
│ │ ├── 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/
|
||||
├── 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개 블록 템플릿을 수정하지 않는다 (신규/변형은 별도 파일)
|
||||
3. 한 번에 모든 블록을 만들지 않는다 (A-3 분류 결과를 보고 우선순위 재조정)
|
||||
4. catalog.yaml 없이 블록을 추가하지 않는다 (카탈로그 미등록 = 디자인 팀장이 모름)
|
||||
5. Kei Persona Agent 코드를 수정하지 않는다
|
||||
6. 블록의 높이를 고정 px로 하드코딩하지 않는다 (모드 독립적이어야 함)
|
||||
7. 기존 `tokens.css`의 값을 변경하지 않는다 (신규 토큰 추가는 가능, 기존 값 변경은 7개 블록 깨짐 위험)
|
||||
8. 이미지를 crop하지 않는다 (원본 그대로, 크기만 조절)
|
||||
9. 그라데이션, 호버, 애니메이션 등 Figma 장식 요소를 가져오지 않는다
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""DA-13b: 텍스트 편집자 — 슬롯 텍스트 정리 (Kei 역할).
|
||||
"""DA-13b: 3단계 — Kei 텍스트 편집자 (텍스트 정리).
|
||||
|
||||
디자인 팀장의 레이아웃 컨셉 + 원본 콘텐츠를 받아,
|
||||
각 슬롯에 맞는 텍스트를 도메인 전문가로서 정리한다.
|
||||
핵심 내용을 유지하면서 슬롯 분량에 맞게 편집.
|
||||
팀장의 글자 수 가이드를 참고하되 내용 의미가 우선.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -18,20 +18,47 @@ from src.design_director import BLOCK_SLOTS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
EDITOR_PROMPT = """당신은 도메인 전문가이자 콘텐츠 편집자이다.
|
||||
원본 콘텐츠의 핵심 내용을 유지하면서 각 블록의 슬롯에 맞게 텍스트를 정리한다.
|
||||
|
||||
## 핵심 원칙
|
||||
- **내용의 의미와 정확성이 글자 수보다 우선한다**
|
||||
- 팀장이 제시한 글자 수 가이드는 참고. 의미를 살리려면 가이드를 초과해도 된다.
|
||||
- 디자인 실무자가 텍스트에 맞게 디자인을 조정할 것이므로, 텍스트를 억지로 자르지 않는다.
|
||||
|
||||
## 편집 규칙
|
||||
- 전체 컨텍스트와 핵심 용어를 보존한다
|
||||
- 세련된 표현으로 편집한다 (원본 그대로가 아님)
|
||||
- 개조식(불릿, 번호)으로 작성한다. 줄글 금지.
|
||||
- 출처가 있는 내용은 출처를 반드시 보존한다
|
||||
- 출처가 없는 수치나 통계를 만들지 않는다
|
||||
|
||||
## 표 편집 규칙
|
||||
- 표는 표로 유지한다 (다른 형태로 전환하지 않음)
|
||||
- 팀장이 요약 요청하면 핵심 행/열만 선택하고 "...외 N건" 표기
|
||||
|
||||
## 자세히보기 편집 규칙
|
||||
- detail_target인 꼭지는 두 버전을 작성:
|
||||
- summary: 슬라이드 표면에 보일 요약 (3줄 이내)
|
||||
- detail: 펼치면 보일 전체 내용
|
||||
|
||||
## JSON 형식으로만 응답한다. 설명 없이 JSON만."""
|
||||
|
||||
|
||||
async def fill_content(
|
||||
content: str,
|
||||
layout_concept: dict[str, Any],
|
||||
analysis: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""각 페이지의 각 블록 슬롯에 텍스트를 채운다.
|
||||
"""3단계: 각 페이지의 각 블록 슬롯에 텍스트를 채운다.
|
||||
|
||||
Args:
|
||||
content: 원본 텍스트 콘텐츠
|
||||
layout_concept: 디자인 팀장의 레이아웃 컨셉
|
||||
{"title": "...", "pages": [{"blocks": [...]}]}
|
||||
analysis: 1단계 실장의 꼭지 분석 결과 (참고용)
|
||||
|
||||
Returns:
|
||||
슬롯이 채워진 layout_concept (pages[n].blocks[m].data에 텍스트 추가)
|
||||
슬롯이 채워진 layout_concept
|
||||
"""
|
||||
client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
|
||||
|
||||
@@ -40,43 +67,30 @@ async def fill_content(
|
||||
if not blocks:
|
||||
continue
|
||||
|
||||
# 슬롯 요구사항 생성
|
||||
# 블록별 슬롯 + 글자 수 가이드 생성
|
||||
slot_requirements = []
|
||||
for i, block in enumerate(blocks):
|
||||
block_type = block["type"]
|
||||
block_type = block.get("type", "")
|
||||
slots = BLOCK_SLOTS.get(block_type, {})
|
||||
slot_requirements.append(
|
||||
f"블록 {i+1} ({block_type}, 영역: {block['area']}):\n"
|
||||
char_guide = block.get("char_guide", {})
|
||||
|
||||
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('optional', [])}\n"
|
||||
f" 용도: {block.get('reason', '미지정')}"
|
||||
f" 선택 슬롯: {slots.get('optional', [])}"
|
||||
)
|
||||
|
||||
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"
|
||||
)
|
||||
if char_guide:
|
||||
guide_lines = [f" {k}: ~{v}자" for k, v in char_guide.items()]
|
||||
req_text += "\n 글자 수 가이드 (참고, 의미 우선):\n" + "\n".join(guide_lines)
|
||||
|
||||
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 = (
|
||||
f"## 원본 콘텐츠\n{content}\n\n"
|
||||
@@ -84,7 +98,8 @@ async def fill_content(
|
||||
+ "\n".join(slot_requirements)
|
||||
+ "\n\n## 요청\n"
|
||||
"위 블록별로 슬롯에 들어갈 텍스트를 정리하여 JSON으로 반환해줘.\n"
|
||||
"원본의 핵심 내용을 충실하게 반영하되, 각 슬롯 분량에 맞게 편집해.\n"
|
||||
"내용의 의미를 살려서 편집해. 글자 수 가이드는 참고만.\n"
|
||||
"자세히보기 대상 블록은 summary + detail 두 버전을 작성해.\n"
|
||||
"형식:\n"
|
||||
'{"blocks": [{"area": "...", "type": "...", "data": {슬롯 키-값}}]}'
|
||||
)
|
||||
@@ -93,7 +108,7 @@ async def fill_content(
|
||||
response = await client.messages.create(
|
||||
model="claude-sonnet-4-20250514",
|
||||
max_tokens=4096,
|
||||
system=system_prompt,
|
||||
system=EDITOR_PROMPT,
|
||||
messages=[{"role": "user", "content": user_prompt}],
|
||||
)
|
||||
|
||||
@@ -103,7 +118,7 @@ async def fill_content(
|
||||
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"):
|
||||
if orig_block.get("area") == filled_block.get("area"):
|
||||
orig_block["data"] = filled_block.get("data", {})
|
||||
break
|
||||
|
||||
@@ -112,7 +127,7 @@ async def fill_content(
|
||||
f"{len(filled['blocks'])}개 블록"
|
||||
)
|
||||
else:
|
||||
logger.warning(f"텍스트 정리 파싱 실패 (페이지 {page_idx + 1}). 기본값 사용.")
|
||||
logger.warning(f"텍스트 정리 파싱 실패 (페이지 {page_idx + 1}). 기본값.")
|
||||
_apply_defaults(blocks)
|
||||
|
||||
except Exception as e:
|
||||
@@ -138,18 +153,20 @@ def _apply_defaults(blocks: list[dict[str, Any]]) -> None:
|
||||
},
|
||||
"process": {"steps": []},
|
||||
"comparison-table": {"headers": [], "rows": []},
|
||||
"image-block": {"src": "", "alt": "이미지"},
|
||||
"details-block": {"summary_text": "(상세 내용)", "detail_content": ""},
|
||||
}
|
||||
for block in blocks:
|
||||
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:
|
||||
"""텍스트에서 JSON을 추출한다."""
|
||||
patterns = [
|
||||
r'```json\s*(.*?)```',
|
||||
r'```\s*(.*?)```',
|
||||
r'(\{.*\})',
|
||||
r"```json\s*(.*?)```",
|
||||
r"```\s*(.*?)```",
|
||||
r"(\{.*\})",
|
||||
]
|
||||
for pattern in patterns:
|
||||
match = re.search(pattern, text, re.DOTALL)
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
"""DA-13: 디자인 팀장 — 레이아웃 컨셉만 (Sonnet).
|
||||
"""DA-13: 2단계 — 디자인 팀장 (레이아웃 설계).
|
||||
|
||||
Opus의 분류 결과 + 원본 콘텐츠를 받아,
|
||||
레이아웃 컨셉(블록 배치 + 페이지 수 + 슬롯 목록)만 결정한다.
|
||||
텍스트 정리는 하지 않는다 — content_editor가 담당.
|
||||
실장의 꼭지 분석 결과를 받아,
|
||||
각 꼭지에 적합한 블록을 매핑하고 공간 배분 + 글자 수 가이드를 결정한다.
|
||||
텍스트 정리는 하지 않는다.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import anthropic
|
||||
@@ -17,7 +18,7 @@ from src.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 블록별 슬롯 정의 (content_editor에서도 참조)
|
||||
# 블록별 슬롯 정의 (content_editor, renderer에서도 참조)
|
||||
BLOCK_SLOTS = {
|
||||
"comparison": {
|
||||
"required": ["left_title", "left_content", "right_title", "right_content"],
|
||||
@@ -47,78 +48,147 @@ BLOCK_SLOTS = {
|
||||
"required": ["headers", "rows"],
|
||||
"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(
|
||||
content: str,
|
||||
classification: dict[str, Any],
|
||||
analysis: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""디자인 팀장이 레이아웃 컨셉을 결정한다.
|
||||
|
||||
텍스트는 채우지 않는다. 블록 배치, 페이지 수, 슬롯 목록만 반환.
|
||||
"""2단계: 디자인 팀장이 레이아웃 컨셉을 설계한다.
|
||||
|
||||
Args:
|
||||
content: 원본 텍스트 콘텐츠
|
||||
classification: Opus의 분류 결과
|
||||
content: 원본 텍스트 (분량 참고용)
|
||||
analysis: 1단계 실장의 꼭지 분석 결과
|
||||
|
||||
Returns:
|
||||
레이아웃 컨셉:
|
||||
{
|
||||
"pages": [
|
||||
{
|
||||
"grid_areas": "...",
|
||||
"grid_columns": "...",
|
||||
"grid_rows": "...",
|
||||
"blocks": [{"area": "header", "type": "quote-block", "reason": "..."}]
|
||||
}
|
||||
],
|
||||
"title": "슬라이드 제목"
|
||||
}
|
||||
{"title": "...", "pages": [{"grid_areas": "...", "blocks": [...]}]}
|
||||
"""
|
||||
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', '')}"
|
||||
)
|
||||
catalog_text = _load_catalog()
|
||||
|
||||
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": "..."}]}]}'
|
||||
# 꼭지 요약
|
||||
topics_summary = []
|
||||
for t in analysis.get("topics", []):
|
||||
line = (
|
||||
f"꼭지 {t['id']}: {t['title']} "
|
||||
f"[{t.get('layer', '?')}, 강조:{t.get('emphasis', False)}, "
|
||||
f"방향:{t.get('direction', '?')}, 유형:{t.get('content_type', 'text')}]"
|
||||
)
|
||||
if t.get("image_info"):
|
||||
line += f" 이미지:{t['image_info']}"
|
||||
if t.get("table_info"):
|
||||
line += f" 표:{t['table_info']}"
|
||||
if t.get("detail_target"):
|
||||
line += " → 자세히보기 대상"
|
||||
topics_summary.append(line)
|
||||
|
||||
system = DIRECTOR_PROMPT.replace("{catalog}", catalog_text)
|
||||
|
||||
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"제목: {analysis.get('title', '')}\n"
|
||||
f"페이지 수: {analysis.get('total_pages', 1)}\n"
|
||||
f"꼭지 목록:\n" + "\n".join(topics_summary) +
|
||||
f"\n\n## 원본 콘텐츠 (분량 참고)\n{content[:2000]}\n\n"
|
||||
f"## 요청\n"
|
||||
f"위 블록을 몇 페이지에 어떻게 배치할지 결정해줘. "
|
||||
f"텍스트는 채우지 마. 배치 구조만 JSON으로 반환해."
|
||||
f"위 꼭지를 어떤 블록으로, 어디에, 몇 페이지로 배치할지 설계해줘.\n"
|
||||
f"텍스트는 채우지 마. 구조만 JSON으로."
|
||||
)
|
||||
|
||||
try:
|
||||
response = await client.messages.create(
|
||||
model="claude-sonnet-4-20250514",
|
||||
max_tokens=2048,
|
||||
system=system_prompt,
|
||||
system=system,
|
||||
messages=[{"role": "user", "content": user_prompt}],
|
||||
)
|
||||
|
||||
@@ -128,29 +198,45 @@ async def create_layout_concept(
|
||||
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"레이아웃 설계 완료: {len(concept['pages'])}페이지, "
|
||||
f"{total_blocks}개 블록"
|
||||
)
|
||||
return concept
|
||||
return {
|
||||
"title": analysis.get("title", "슬라이드"),
|
||||
**concept,
|
||||
}
|
||||
else:
|
||||
logger.warning("레이아웃 컨셉 파싱 실패. 기존 분류를 1페이지로 사용.")
|
||||
logger.warning("레이아웃 설계 파싱 실패. fallback 사용.")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"디자인 팀장 호출 실패: {e}", exc_info=True)
|
||||
|
||||
# fallback: 기존 분류를 1페이지로 감싸기
|
||||
return _fallback_single_page(classification)
|
||||
# fallback
|
||||
return _fallback_layout(analysis)
|
||||
|
||||
|
||||
def _fallback_single_page(classification: dict[str, Any]) -> dict[str, Any]:
|
||||
"""분류 결과를 1페이지 컨셉으로 변환 (fallback)."""
|
||||
def _fallback_layout(analysis: dict[str, Any]) -> dict[str, Any]:
|
||||
"""팀장 실패 시 기본 레이아웃."""
|
||||
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 {
|
||||
"title": classification.get("title", "슬라이드"),
|
||||
"title": analysis.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", []),
|
||||
"grid_areas": "'header' 'main' 'footer'",
|
||||
"grid_columns": "1fr",
|
||||
"grid_rows": "auto 1fr auto",
|
||||
"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:
|
||||
"""텍스트에서 JSON을 추출한다."""
|
||||
patterns = [
|
||||
r'```json\s*(.*?)```',
|
||||
r'```\s*(.*?)```',
|
||||
r'(\{.*\})',
|
||||
r"```json\s*(.*?)```",
|
||||
r"```\s*(.*?)```",
|
||||
r"(\{.*\})",
|
||||
]
|
||||
for pattern in patterns:
|
||||
match = re.search(pattern, text, re.DOTALL)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -17,45 +16,56 @@ from src.config import settings
|
||||
|
||||
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개인데 내용이 많으면 → 세부 내용은 "자세히보기" 대상으로 표시
|
||||
|
||||
## 규칙
|
||||
- 콘텐츠를 분석하여 각 덩어리의 유형을 판단한다
|
||||
- 한 슬라이드에 블록 4~6개가 적절하다
|
||||
- 정보 계층: 위→아래 (문제 제기 → 분석 → 결론)
|
||||
- 반드시 JSON으로만 응답한다. 설명 없이 JSON만.
|
||||
## 각 꼭지 분석 항목
|
||||
1. **레이어 수준**: 도입(문제 제기, 배경) / 핵심(핵심 내용, 정의) / 보조(사례, 근거) / 결론(요약, 핵심 메시지)
|
||||
2. **강조**: 눈에 띄게 해야 하는 꼭지 표시 (true/false)
|
||||
3. **배치 방향**: 세로로 긴 내용(vertical) / 가로로 나열(horizontal) / 유연(flexible)
|
||||
4. **콘텐츠 유형**: text(텍스트) / image(이미지) / table(표) / mixed(혼합)
|
||||
5. **이미지 정보** (이미지가 있는 경우):
|
||||
- 핵심인지 보조인지 (core/supplementary)
|
||||
- 텍스트 포함 여부 (도표/차트는 true)
|
||||
6. **표 정보** (표가 있는 경우):
|
||||
- 대략적 행/열 수
|
||||
- 전체 표시 가능한지 판단
|
||||
7. **자세히보기 대상**: 너무 구체적/세부적인 내용은 detail_target: true
|
||||
|
||||
## 출력 형식
|
||||
## 출력 형식 (반드시 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": "핵심 결론"}
|
||||
"total_pages": 1,
|
||||
"topics": [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "꼭지 제목",
|
||||
"summary": "꼭지 내용 요약 (1~2줄)",
|
||||
"layer": "intro|core|supporting|conclusion",
|
||||
"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:
|
||||
"""Opus API를 직접 호출하여 콘텐츠를 분류한다.
|
||||
"""1단계: 본문에서 꼭지를 추출하고 분석한다.
|
||||
|
||||
Args:
|
||||
content: 원본 텍스트 콘텐츠
|
||||
@@ -75,34 +85,38 @@ async def classify_content(content: str) -> dict[str, Any] | None:
|
||||
max_tokens=2048,
|
||||
system=CLASSIFICATION_PROMPT,
|
||||
messages=[
|
||||
{"role": "user", "content": f"다음 콘텐츠의 레이아웃을 결정해줘:\n\n{content}"}
|
||||
{
|
||||
"role": "user",
|
||||
"content": f"다음 콘텐츠를 분석하여 꼭지를 추출하고 구조를 설계해줘:\n\n{content}",
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
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(
|
||||
f"콘텐츠 분류 완료: {layout.get('title', 'untitled')}, "
|
||||
f"{len(layout['blocks'])}개 블록"
|
||||
f"꼭지 추출 완료: {analysis.get('title', 'untitled')}, "
|
||||
f"{len(analysis['topics'])}개 꼭지, "
|
||||
f"{analysis.get('total_pages', 1)}페이지"
|
||||
)
|
||||
return layout
|
||||
return analysis
|
||||
else:
|
||||
logger.warning(f"분류 JSON 파싱 실패. 응답: {result_text[:200]}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Opus 분류 호출 실패: {e}")
|
||||
logger.warning(f"실장 분류 호출 실패: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _parse_layout_json(text: str) -> dict[str, Any] | None:
|
||||
"""텍스트에서 레이아웃 JSON을 추출한다."""
|
||||
def _parse_json(text: str) -> dict[str, Any] | None:
|
||||
"""텍스트에서 JSON을 추출한다."""
|
||||
patterns = [
|
||||
r'```json\s*(.*?)```',
|
||||
r'```\s*(.*?)```',
|
||||
r'(\{.*\})',
|
||||
r"```json\s*(.*?)```",
|
||||
r"```\s*(.*?)```",
|
||||
r"(\{.*\})",
|
||||
]
|
||||
for pattern in patterns:
|
||||
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]:
|
||||
"""Opus 실패 시 기본 레이아웃을 반환하는 fallback."""
|
||||
"""실장 분류 실패 시 기본 구조를 반환하는 fallback."""
|
||||
return {
|
||||
"title": "슬라이드",
|
||||
"grid_areas": "'header' 'main' 'footer'",
|
||||
"grid_columns": "1fr",
|
||||
"grid_rows": "auto 1fr auto",
|
||||
"blocks": [
|
||||
"total_pages": 1,
|
||||
"topics": [
|
||||
{
|
||||
"area": "header",
|
||||
"type": "quote-block",
|
||||
"reason": "기본 인용 블록",
|
||||
},
|
||||
{
|
||||
"area": "main",
|
||||
"type": "card-grid",
|
||||
"reason": "기본 카드 그리드",
|
||||
},
|
||||
{
|
||||
"area": "footer",
|
||||
"type": "conclusion-bar",
|
||||
"reason": "기본 결론",
|
||||
"id": 1,
|
||||
"title": "핵심 내용",
|
||||
"summary": content[:100],
|
||||
"layer": "core",
|
||||
"emphasis": False,
|
||||
"direction": "flexible",
|
||||
"content_type": "text",
|
||||
"detail_target": False,
|
||||
"page": 1,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
200
src/pipeline.py
200
src/pipeline.py
@@ -1,16 +1,24 @@
|
||||
"""DA-14: 전체 파이프라인 (3단계).
|
||||
"""DA-14: 전체 파이프라인 (5단계).
|
||||
|
||||
콘텐츠 입력 → Opus 분류 → 디자인 팀장 컨셉 → 텍스트 편집자 정리 → 렌더러 조립 → HTML 출력.
|
||||
1. Kei 실장: 꼭지 추출 + 분석
|
||||
2. 디자인 팀장: 레이아웃 설계
|
||||
3. 텍스트 편집자: 텍스트 정리
|
||||
4. 디자인 실무자: HTML 조립
|
||||
5. 디자인 팀장: 전체 재검토
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, AsyncIterator
|
||||
|
||||
import anthropic
|
||||
|
||||
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.renderer import render_slide
|
||||
from src.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -19,53 +27,183 @@ async def generate_slide(
|
||||
content: str,
|
||||
manual_layout: dict[str, Any] | None = None,
|
||||
) -> AsyncIterator[dict[str, str]]:
|
||||
"""콘텐츠를 슬라이드 HTML로 변환하는 전체 파이프라인.
|
||||
|
||||
Args:
|
||||
content: 원본 텍스트 콘텐츠
|
||||
manual_layout: 수동 레이아웃 명세 (Opus 대신 사용)
|
||||
"""콘텐츠를 슬라이드 HTML로 변환하는 5단계 파이프라인.
|
||||
|
||||
Yields:
|
||||
SSE 이벤트:
|
||||
{"event": "progress", "data": "단계 설명"}
|
||||
{"event": "result", "data": "완성 HTML"}
|
||||
{"event": "error", "data": "에러 메시지"}
|
||||
SSE 이벤트: progress / result / error
|
||||
"""
|
||||
try:
|
||||
# 1단계: Kei 실장 (Opus) — 콘텐츠 분류
|
||||
yield {"event": "progress", "data": "1/4 Kei 실장이 콘텐츠를 분석 중..."}
|
||||
# 1단계: Kei 실장 — 꼭지 추출 + 분석
|
||||
yield {"event": "progress", "data": "1/5 Kei 실장이 꼭지를 추출 중..."}
|
||||
|
||||
if manual_layout:
|
||||
classification = manual_layout
|
||||
analysis = manual_layout
|
||||
else:
|
||||
classification = await classify_content(content)
|
||||
if classification is None:
|
||||
classification = manual_classify(content)
|
||||
analysis = await classify_content(content)
|
||||
if analysis is None:
|
||||
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단계: 디자인 팀장 — 레이아웃 컨셉
|
||||
yield {"event": "progress", "data": "2/4 디자인 팀장이 레이아웃을 설계 중..."}
|
||||
# 2단계: 디자인 팀장 — 레이아웃 설계
|
||||
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(len(p.get("blocks", [])) for p in layout_concept.get("pages", []))
|
||||
logger.info(f"레이아웃 컨셉: {total_pages}페이지, {total_blocks}개 블록")
|
||||
total_blocks = sum(
|
||||
len(p.get("blocks", [])) for p in layout_concept.get("pages", [])
|
||||
)
|
||||
logger.info(
|
||||
f"2단계 완료: {len(layout_concept.get('pages', []))}페이지, "
|
||||
f"{total_blocks}개 블록"
|
||||
)
|
||||
|
||||
# 3단계: 텍스트 편집자 (Kei 역할) — 슬롯 텍스트 정리
|
||||
yield {"event": "progress", "data": "3/4 텍스트 편집자가 핵심을 정리 중..."}
|
||||
# 3단계: 텍스트 편집자 — 텍스트 정리
|
||||
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 렌더링
|
||||
yield {"event": "progress", "data": "4/4 슬라이드를 조립 중..."}
|
||||
# 4단계: 디자인 실무자 — HTML 조립
|
||||
yield {"event": "progress", "data": "4/5 디자인 실무자가 슬라이드를 조립 중..."}
|
||||
|
||||
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}
|
||||
logger.info(f"슬라이드 생성 완료: {total_pages}페이지")
|
||||
logger.info(f"슬라이드 생성 완료: {len(layout_concept.get('pages', []))}페이지")
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"파이프라인 오류: {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
|
||||
|
||||
Reference in New Issue
Block a user