Files
_Geulbeot/04. design_agent/IMPROVEMENT-PHASE-D.md
kyeongmin 688ddbbb17 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>
2026-03-25 18:47:13 +09:00

14 KiB
Raw Blame History

Phase D: 이미지 처리 — 실행 상세

MDX 콘텐츠의 이미지 참조(![alt](/assets/images/DX1.png))를 감지하고, 로컬 이미지 파일의 크기를 측정하여 배치 판단에 활용. 서버가 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는 선택)
// 이미지 참조 감지
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 모델 확장

class SlideRequest(BaseModel):
    content: str
    base_path: str = ""  # 이미지 기준 폴더 (선택)

3) src/main.py — generate 엔드포인트에서 base_path 전달

async for event in generate_slide(req.content, base_path=req.base_path):

4) src/pipeline.py — generate_slide() 시그니처 확장

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 추가

dependencies = [
    ...
    "Pillow>=10.0",
]

2) src/image_utils.py 신규 제작

"""이미지 크기 측정 유틸리티."""
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에 전달

# 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에 이미지 정보 포함:

# 이미지 크기 정보가 있으면 프롬프트에 포함
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 정보도 함께 포함:

# 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로 변환:

# 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에서 ![alt](path) 추출 → 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 그대로 반환 (기존과 동일)