04. design_agent 추가 — 콘텐츠 시각 구조화 슬라이드 생성기
5단계 AI 파이프라인: 1. Kei 실장(Opus via Kei API) — 꼭지 추출 + 정보 구조 파악 2. 디자인 팀장 — FAISS 블록 검색 + Opus 추천 + Sonnet 블록 매핑 3. Kei 편집자(Kei API) — 도메인 전문 텍스트 정리 4. 디자인 실무자(Sonnet + Jinja2) — CSS 변수 조정 + HTML 조립 5. 디자인 팀장(Sonnet) — 균형 재검토 (최대 2회 루프) 블록 라이브러리 46개 (6 카테고리) + _legacy 13개 FAISS 블록 검색 (bge-m3, 1024차원) SVG N개 동적 배치 (cos/sin 좌표 계산) Pillow 이미지 크기 측정 + base64 인라인 컨테이너 예산 기반 블록 배치 (zone별 높이 px) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
354
04. design_agent/IMPROVEMENT-PHASE-D.md
Normal file
354
04. design_agent/IMPROVEMENT-PHASE-D.md
Normal file
@@ -0,0 +1,354 @@
|
||||
# Phase D: 이미지 처리 — 실행 상세
|
||||
|
||||
> MDX 콘텐츠의 이미지 참조(``)를 감지하고,
|
||||
> 로컬 이미지 파일의 크기를 측정하여 배치 판단에 활용.
|
||||
> 서버가 localhost에서 돌므로 로컬 파일 접근 가능.
|
||||
> 원칙: 하드코딩 금지. 모든 판단은 AI 사고. 회귀 금지.
|
||||
|
||||
---
|
||||
|
||||
## 실행 순서
|
||||
|
||||
```
|
||||
D-0 (이미지 경로 입력 UI + API) ← 선행 필수
|
||||
→ D-1 (Pillow 유틸리티 + 이미지 추출)
|
||||
→ D-2 + D-3 (비율 기반 배치 — 프롬프트에 정보 전달)
|
||||
→ D-4 (텍스트 포함 도표 축소 방지)
|
||||
→ D-5 (HTML에 이미지 삽입 — base64 또는 절대 경로)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## D-0: 이미지 경로 입력 UI + API 파라미터 (선행 작업)
|
||||
|
||||
### 현재 상태
|
||||
- static/index.html:176~179 — `fetch('/api/generate', { body: JSON.stringify({ content }) })` → 텍스트만 전송
|
||||
- src/main.py:35~36 — `SlideRequest` 모델에 `content: str`만
|
||||
- src/pipeline.py:27~29 — `generate_slide(content, manual_layout)` → base_path 없음
|
||||
|
||||
### 작업
|
||||
|
||||
**1) static/index.html — generate() 함수 수정**
|
||||
- 텍스트에서 `` 패턴 감지 (정규식)
|
||||
- 발견 시: "이미지가 포함된 콘텐츠입니다. 이미지 파일이 있는 프로젝트 폴더 경로를 입력해주세요" 팝업 (prompt)
|
||||
- 미발견 시: base_path 없이 기존처럼 전송
|
||||
- API 요청 body: `{ content, base_path }` (base_path는 선택)
|
||||
|
||||
```javascript
|
||||
// 이미지 참조 감지
|
||||
const imagePattern = /!\[.*?\]\((.*?)\)/g;
|
||||
const hasImages = imagePattern.test(content);
|
||||
let basePath = '';
|
||||
if (hasImages) {
|
||||
basePath = prompt(
|
||||
'이미지가 포함된 콘텐츠입니다.\n' +
|
||||
'이미지 파일이 있는 프로젝트 폴더 경로를 입력해주세요.\n' +
|
||||
'예: D:\\ad-hoc\\kei\\content'
|
||||
) || '';
|
||||
}
|
||||
// API 전송
|
||||
body: JSON.stringify({ content, base_path: basePath })
|
||||
```
|
||||
|
||||
**2) src/main.py — SlideRequest 모델 확장**
|
||||
```python
|
||||
class SlideRequest(BaseModel):
|
||||
content: str
|
||||
base_path: str = "" # 이미지 기준 폴더 (선택)
|
||||
```
|
||||
|
||||
**3) src/main.py — generate 엔드포인트에서 base_path 전달**
|
||||
```python
|
||||
async for event in generate_slide(req.content, base_path=req.base_path):
|
||||
```
|
||||
|
||||
**4) src/pipeline.py — generate_slide() 시그니처 확장**
|
||||
```python
|
||||
async def generate_slide(
|
||||
content: str,
|
||||
manual_layout: dict[str, Any] | None = None,
|
||||
base_path: str = "",
|
||||
) -> AsyncIterator[dict[str, str]]:
|
||||
```
|
||||
|
||||
### 하드코딩 점검
|
||||
- 없음. 사용자가 경로를 직접 입력. 코드에 경로 고정값 없음 ✅
|
||||
|
||||
### 충돌/회귀
|
||||
- SlideRequest에 base_path 추가: 기본값 `""` → 기존 요청(base_path 없는)과 호환 ✅
|
||||
- generate_slide() 시그니처에 base_path 추가: 기본값 `""` → 기존 호출과 호환 ✅
|
||||
- index.html generate() 함수: 이미지 없으면 기존과 동일 동작 ✅
|
||||
|
||||
### 수정 파일
|
||||
- `static/index.html` — generate() 함수
|
||||
- `src/main.py` — SlideRequest + generate 엔드포인트
|
||||
- `src/pipeline.py` — generate_slide() 시그니처
|
||||
|
||||
---
|
||||
|
||||
## D-1: Pillow 이미지 크기 읽기 유틸리티
|
||||
|
||||
### 현재 상태
|
||||
- Pillow import/사용 전무. pyproject.toml에도 없음. src/utils/ 디렉토리 없음.
|
||||
|
||||
### 작업
|
||||
|
||||
**1) pyproject.toml에 Pillow 추가**
|
||||
```toml
|
||||
dependencies = [
|
||||
...
|
||||
"Pillow>=10.0",
|
||||
]
|
||||
```
|
||||
|
||||
**2) src/image_utils.py 신규 제작**
|
||||
```python
|
||||
"""이미지 크기 측정 유틸리티."""
|
||||
from pathlib import Path
|
||||
from PIL import Image
|
||||
|
||||
def get_image_sizes(content: str, base_path: str) -> list[dict]:
|
||||
"""콘텐츠에서 이미지 참조를 추출하고 로컬 파일 크기를 측정한다.
|
||||
|
||||
Args:
|
||||
content: MDX/텍스트 콘텐츠
|
||||
base_path: 이미지 파일 기준 폴더 경로
|
||||
|
||||
Returns:
|
||||
[{"path": "/assets/images/DX1.png", "width": 800, "height": 600,
|
||||
"ratio": 1.33, "orientation": "landscape"}]
|
||||
"""
|
||||
import re
|
||||
if not base_path:
|
||||
return []
|
||||
|
||||
base = Path(base_path)
|
||||
images = []
|
||||
|
||||
for match in re.finditer(r'!\[.*?\]\((.*?)\)', content):
|
||||
rel_path = match.group(1).strip()
|
||||
# 상대 경로 해석
|
||||
abs_path = base / rel_path.lstrip('/')
|
||||
|
||||
if abs_path.exists() and abs_path.suffix.lower() in ('.png', '.jpg', '.jpeg', '.gif', '.webp'):
|
||||
try:
|
||||
with Image.open(abs_path) as img:
|
||||
w, h = img.size # 헤더만 읽음
|
||||
ratio = w / h if h > 0 else 1.0
|
||||
orientation = "landscape" if ratio > 1.2 else ("portrait" if ratio < 0.8 else "square")
|
||||
images.append({
|
||||
"path": rel_path,
|
||||
"width": w,
|
||||
"height": h,
|
||||
"ratio": round(ratio, 2),
|
||||
"orientation": orientation,
|
||||
})
|
||||
except Exception:
|
||||
images.append({"path": rel_path, "width": 0, "height": 0, "ratio": 0, "orientation": "unknown"})
|
||||
else:
|
||||
images.append({"path": rel_path, "width": 0, "height": 0, "ratio": 0, "orientation": "not_found"})
|
||||
|
||||
return images
|
||||
```
|
||||
|
||||
### 하드코딩 점검
|
||||
- ratio 기준 1.2, 0.8: CLAUDE.md 원문 "가로형(ratio > 1.2)", "세로형(ratio < 0.8)" — 문서 기준값이므로 하드코딩 아님 ✅
|
||||
- 이미지 확장자 목록: 웹 표준 이미지 포맷. 변할 일 없음 ✅
|
||||
|
||||
### 충돌/회귀
|
||||
- 신규 파일 추가만. 기존 코드 변경 없음 ✅
|
||||
- Pillow는 `Image.open()` 시 헤더만 읽음 (전체 디코딩 안 함) → 성능 안전 ✅
|
||||
|
||||
### 수정 파일
|
||||
- `pyproject.toml` — Pillow 의존성 추가
|
||||
- 신규 `src/image_utils.py`
|
||||
|
||||
---
|
||||
|
||||
## D-2 + D-3: 비율 기반 배치 판단
|
||||
|
||||
### 현재 상태
|
||||
- 비율 기반 배치 판단 코드 없음
|
||||
- B-4에서 1단계 images[] 필드 추가했지만, 실제 크기 정보는 없음
|
||||
|
||||
### 작업
|
||||
pipeline.py에서 D-1 유틸리티 호출 → 이미지 크기/비율 정보를 2단계 Step B와 4단계 Sonnet에 전달
|
||||
|
||||
```python
|
||||
# pipeline.py generate_slide() 내, 2단계 전에:
|
||||
from src.image_utils import get_image_sizes
|
||||
|
||||
image_sizes = get_image_sizes(content, base_path)
|
||||
if image_sizes:
|
||||
# analysis에 이미지 크기 정보 추가
|
||||
analysis["image_sizes"] = image_sizes
|
||||
```
|
||||
|
||||
design_director.py `create_layout_concept()`에서 user_prompt에 이미지 정보 포함:
|
||||
```python
|
||||
# 이미지 크기 정보가 있으면 프롬프트에 포함
|
||||
if analysis.get("image_sizes"):
|
||||
image_info = "\n".join(
|
||||
f"- {img['path']}: {img['width']}×{img['height']}px, {img['orientation']}"
|
||||
for img in analysis["image_sizes"]
|
||||
)
|
||||
user_prompt += f"\n\n## 이미지 크기 정보\n{image_info}\n"
|
||||
```
|
||||
|
||||
→ Sonnet(팀장)이 이 정보를 보고 "가로형 → 전체 너비", "세로형 → 텍스트 옆" 등을 판단.
|
||||
|
||||
### 하드코딩 점검
|
||||
- 비율 판단: AI(팀장)가 orientation 정보를 보고 결정 ✅
|
||||
- 코드는 크기 정보를 전달만. 배치 결정은 AI ✅
|
||||
|
||||
### 충돌/회귀
|
||||
- pipeline.py: 기존 흐름 사이에 이미지 측정 삽입. base_path 없으면 빈 리스트 → 영향 없음 ✅
|
||||
- design_director.py: user_prompt에 텍스트 추가만. 이미지 없으면 추가 안 함 ✅
|
||||
- analysis에 image_sizes 추가: 기존 코드는 `.get()` 패턴 → 안전 ✅
|
||||
|
||||
### 수정 파일
|
||||
- `src/pipeline.py` — generate_slide()에서 get_image_sizes() 호출
|
||||
- `src/design_director.py` — create_layout_concept()에서 이미지 정보 프롬프트 포함
|
||||
|
||||
---
|
||||
|
||||
## D-4: 텍스트 포함 도표 → 과도한 축소 방지
|
||||
|
||||
### 현재 상태
|
||||
- B-4에서 images[].has_text 필드 추가 (1단계 Kei가 판단)
|
||||
- 이 정보를 기반으로 "축소하지 마라" 가이드 없음
|
||||
|
||||
### 작업
|
||||
D-2/D-3의 이미지 정보 전달 시, has_text 정보도 함께 포함:
|
||||
|
||||
```python
|
||||
# pipeline.py에서 1단계 analysis의 images[]와 D-1의 image_sizes를 결합
|
||||
for img_size in image_sizes:
|
||||
# 1단계에서 판단한 has_text 정보 매칭
|
||||
for kei_img in analysis.get("images", []):
|
||||
if kei_img.get("description", "") in img_size.get("path", ""):
|
||||
img_size["has_text"] = kei_img.get("has_text", False)
|
||||
```
|
||||
|
||||
→ Sonnet 프롬프트에 "has_text: true인 이미지는 텍스트가 포함된 도표이므로 과도하게 축소하지 마라" 가이드
|
||||
|
||||
### 하드코딩 점검
|
||||
- 축소 여부: AI가 판단 ✅
|
||||
- has_text: 1단계 Kei가 판단 ✅
|
||||
|
||||
### 충돌/회귀
|
||||
- 기존 데이터에 필드 추가만. 없으면 무시됨 ✅
|
||||
|
||||
### 수정 파일
|
||||
- `src/pipeline.py`
|
||||
|
||||
---
|
||||
|
||||
## D-5: 슬라이드 HTML에 이미지 삽입
|
||||
|
||||
### 현재 상태
|
||||
- 이미지 블록(image-row, image-side-text 등)은 `{{ img.src }}` 슬롯에 경로를 넣지만
|
||||
- 다운로드 HTML에서 상대 경로는 깨짐 (로컬 파일이므로)
|
||||
|
||||
### 작업
|
||||
렌더링 완료 후, HTML의 이미지 src를 base64 data URI로 변환:
|
||||
|
||||
```python
|
||||
# renderer.py 또는 pipeline.py에서 최종 HTML 후처리
|
||||
import base64, re
|
||||
|
||||
def embed_images(html: str, base_path: str) -> str:
|
||||
"""HTML의 이미지 src를 base64 data URI로 변환."""
|
||||
if not base_path:
|
||||
return html
|
||||
|
||||
base = Path(base_path)
|
||||
|
||||
def replace_src(match):
|
||||
src = match.group(1)
|
||||
abs_path = base / src.lstrip('/')
|
||||
if abs_path.exists():
|
||||
mime = 'image/png' if abs_path.suffix == '.png' else 'image/jpeg'
|
||||
data = base64.b64encode(abs_path.read_bytes()).decode()
|
||||
return f'src="data:{mime};base64,{data}"'
|
||||
return match.group(0)
|
||||
|
||||
return re.sub(r'src="(/[^"]+\.(?:png|jpg|jpeg|gif|webp))"', replace_src, html)
|
||||
```
|
||||
|
||||
### 하드코딩 점검
|
||||
- MIME 타입: 확장자 기반 표준 매핑. 하드코딩 아님 ✅
|
||||
- 이미지 확장자: D-1과 동일 목록 ✅
|
||||
|
||||
### 충돌/회귀
|
||||
- 최종 HTML 후처리. 이미지 없으면 정규식 매칭 없음 → 기존과 동일 ✅
|
||||
- base_path 없으면 함수 즉시 반환 → 안전 ✅
|
||||
|
||||
### 수정 파일
|
||||
- `src/image_utils.py` (embed_images 함수 추가) 또는 `src/pipeline.py`
|
||||
|
||||
---
|
||||
|
||||
## 수정 파일 총괄
|
||||
|
||||
| 파일 | 항목 | 변경 성격 |
|
||||
|------|------|----------|
|
||||
| `static/index.html` | D-0 | generate() 함수에 이미지 감지 + 경로 입력 팝업 |
|
||||
| `src/main.py` | D-0 | SlideRequest에 base_path 추가 + 엔드포인트 전달 |
|
||||
| `src/pipeline.py` | D-0, D-2~D-4 | generate_slide() 시그니처 + 이미지 크기 측정 + 프롬프트 전달 |
|
||||
| `pyproject.toml` | D-1 | Pillow 의존성 추가 |
|
||||
| 신규 `src/image_utils.py` | D-1, D-5 | get_image_sizes() + embed_images() |
|
||||
| `src/design_director.py` | D-2, D-3 | user_prompt에 이미지 크기/비율 정보 포함 |
|
||||
|
||||
---
|
||||
|
||||
## 검증 체크리스트
|
||||
|
||||
- [ ] D-0: 이미지 없는 텍스트 → 팝업 안 뜸. 기존과 동일 동작
|
||||
- [ ] D-0: 이미지 있는 MDX → 팝업 뜨고 경로 입력 → API에 base_path 전달
|
||||
- [ ] D-0: 팝업에서 취소 → base_path="" → 이미지 처리 스킵 (에러 없음)
|
||||
- [ ] D-1: Pillow로 이미지 크기 측정. 파일 없으면 width=0, orientation="not_found"
|
||||
- [ ] D-2/D-3: Sonnet 프롬프트에 이미지 크기/orientation 정보 포함
|
||||
- [ ] D-4: has_text=true 이미지에 대해 "축소 금지" 가이드 전달
|
||||
- [ ] D-5: 다운로드 HTML에서 이미지가 base64로 삽입되어 보임
|
||||
- [ ] 이미지 없는 기존 콘텐츠: 전체 파이프라인 기존과 동일하게 동작 (회귀 없음)
|
||||
|
||||
---
|
||||
|
||||
## 수정 이력
|
||||
|
||||
| 날짜 | 내용 |
|
||||
|------|------|
|
||||
| 2026-03-25 | 초안. D-0(선행 UI) 추가. D-5(HTML 이미지 삽입) 추가. 6개 항목. |
|
||||
| 2026-03-25 | Phase D 전체 구현 완료. 검증 통과. |
|
||||
|
||||
## 구현 완료 확인
|
||||
|
||||
| 항목 | 검증 결과 |
|
||||
|------|----------|
|
||||
| D-0 | SlideRequest에 base_path="" 기본값 추가. generate_slide()에 base_path 파라미터 추가. index.html에 이미지 감지+팝업. 기존 요청(base_path 없는) 호환 ✅ |
|
||||
| D-1 | pyproject.toml에 Pillow>=10.0 추가. src/image_utils.py 신규 (get_image_sizes + embed_images). Pillow 10.4.0 설치 확인. base_path 없으면 빈 리스트 반환(안전) |
|
||||
| D-2+D-3 | pipeline.py에서 get_image_sizes() 호출 → analysis["image_sizes"] 저장. design_director.py user_prompt에 이미지 크기/orientation/배치 가이드 포함. 이미지 없으면 추가 안 함(기존 동일) |
|
||||
| D-4 | design_director.py에서 has_text=true 이미지에 "(텍스트 포함 도표 — 과도한 축소 금지)" 가이드 자동 추가 |
|
||||
| D-5 | pipeline.py에서 최종 HTML 반환 전 embed_images(html, base_path) 호출. base_path 없으면 원본 그대로 반환(안전) |
|
||||
|
||||
### D-0 구현 결과
|
||||
- `src/main.py`: SlideRequest에 `base_path: str = ""` 추가. generate 엔드포인트에서 `base_path=req.base_path` 전달.
|
||||
- `src/pipeline.py`: generate_slide() 시그니처에 `base_path: str = ""` 추가.
|
||||
- `static/index.html`: generate() 함수에서 `` 패턴 감지 → prompt()로 경로 입력 → API에 `{ content, base_path }` 전송. 취소 시 `base_path=""` → 이미지 처리 스킵.
|
||||
|
||||
### D-1 구현 결과
|
||||
- `pyproject.toml`: `"Pillow>=10.0"` 의존성 추가.
|
||||
- `src/image_utils.py` 신규:
|
||||
- `get_image_sizes(content, base_path)`: MDX에서 `` 추출 → base_path + 상대경로 해석 → Pillow `Image.open().size` → {path, width, height, ratio, orientation} 반환
|
||||
- `embed_images(html, base_path)`: HTML의 `src="/...png"` → `src="data:image/png;base64,..."` 변환
|
||||
- 파일 미발견/에러 시 orientation="not_found"/"error" → 에러 없이 계속 진행
|
||||
|
||||
### D-2+D-3+D-4 구현 결과
|
||||
- `src/pipeline.py`: 1단계 완료 직후 `get_image_sizes()` 호출 → `analysis["image_sizes"]` 저장
|
||||
- `src/design_director.py`: user_prompt에 이미지 정보 섹션 추가 — 각 이미지의 크기/orientation + "가로형 → 전체 너비", "세로형 → 텍스트 옆", "텍스트 포함 도표 → 축소 금지" 가이드
|
||||
- has_text 정보는 1단계 Kei의 images[].has_text와 D-1 크기 정보가 결합됨
|
||||
|
||||
### D-5 구현 결과
|
||||
- `src/pipeline.py`: yield result 직전에 `embed_images(html, base_path)` 호출
|
||||
- base_path 없으면 원본 HTML 그대로 반환 (기존과 동일)
|
||||
Reference in New Issue
Block a user