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:
2026-03-25 18:47:13 +09:00
parent 9b905a4313
commit 688ddbbb17
244 changed files with 23955 additions and 0 deletions

View File

@@ -0,0 +1,396 @@
# Phase 2 기술 검토 보고서
각 항목별로 **정확한 구현 방법, 기존 코드 충돌 여부, 회귀 위험, 대충 처리 위험**을 검토한다.
---
## Phase 2-A: FAISS 블록 검색
### 현재 코드 상태
```
design_director.py line 184~188: _load_catalog()
→ catalog.yaml 전문을 문자열로 읽어서 프롬프트에 통째로 넣음
→ 46개 블록 전체 설명 = 약 8,000~10,000 토큰
design_director.py line 294: catalog_text = _load_catalog()
design_director.py line 322: catalog=catalog_text # 프롬프트에 삽입
```
### 정확한 구현 방법
**1. 임베딩 모델 선택**
```
Kei persona가 사용하는 모델: BAAI/bge-m3 (1024차원)
위치: D:\ad-hoc\kei\persona_agent\backend\llm\retriever.py line 49
design_agent에서도 동일 모델 사용:
→ 한국어 지원 ✅
→ Kei에서 검증됨 ✅
→ 1024차원으로 46개 벡터 = 약 184KB (가벼움)
```
**2. 인덱스 구축 (1회성, 오프라인)**
```python
# src/block_search.py (신규 파일)
import faiss
import yaml
from sentence_transformers import SentenceTransformer
def build_block_index():
# 1. catalog.yaml 로드
with open("templates/catalog.yaml") as f:
catalog = yaml.safe_load(f)
# 2. 각 블록의 검색용 텍스트 생성
texts = []
ids = []
for block in catalog["blocks"]:
text = f"{block['name']}. {block['visual']}. {block['when']}"
texts.append(text)
ids.append(block["id"])
# 3. 임베딩
model = SentenceTransformer("BAAI/bge-m3")
embeddings = model.encode(texts, normalize_embeddings=True)
# 4. FAISS 인덱스 생성
dim = embeddings.shape[1]
index = faiss.IndexFlatIP(dim) # Inner Product (코사인 유사도)
index.add(embeddings)
# 5. 저장
faiss.write_index(index, "data/block_index.faiss")
# ids 매핑도 저장
```
**3. 검색 (런타임, 매 요청)**
```python
def search_blocks(query: str, top_k: int = 8) -> list[dict]:
"""콘텐츠 꼭지 설명으로 적합한 블록 검색"""
embedding = model.encode([query], normalize_embeddings=True)
scores, indices = index.search(embedding, top_k)
return [catalog_blocks[i] for i in indices[0]]
```
**4. design_director.py 수정 지점**
```
현재 line 294: catalog_text = _load_catalog() # 전문
변경: catalog_text = search_blocks(topics_summary, top_k=8) # 관련 8개만
```
### 충돌 검토
| 파일 | 영향 | 충돌? |
|------|------|-------|
| design_director.py | _load_catalog() 반환값이 문자열 → 문자열(검색결과) | ❌ (인터페이스 동일) |
| pipeline.py | 호출하지 않음 | ❌ |
| renderer.py | _load_catalog_map()은 별도 함수 (경로 매핑용) | ❌ (다른 함수) |
| content_editor.py | BLOCK_SLOTS만 참조 | ❌ |
### 회귀 위험
- _load_catalog()를 교체하므로, 검색이 실패하면 catalog 전문을 fallback으로 넘겨야 함
- FAISS 인덱스 파일이 없으면 기존 방식(전문)으로 동작해야 함
### 대충 처리 위험
- ⚠️ "검색 결과 8개만 넣으면 되지" → 검색 품질이 낮으면 적합한 블록이 빠질 수 있음
- 대응: 검색 결과 + 카테고리별 최소 1개 보장 (8개 중 카테고리 커버 확인)
---
## Phase 2-B: SVG N개 자동 배치
### 현재 코드 상태
```
templates/blocks/visuals/venn-diagram.html:
→ 3개 원 좌표가 하드코딩 (cx="265" cy="300", cx="370" cy="230", cx="365" cy="355")
→ items[0], items[1], items[2]로 직접 인덱싱
renderer.py:
→ render_standalone_block()에서 block_data를 Jinja2에 **kwargs로 전달
→ 별도 전처리 없음
```
### 정확한 구현 방법
**1. 좌표 계산 함수 (신규)**
```python
# src/svg_calculator.py (신규 파일)
import math
def calc_circle_positions(
n: int,
center_x: float = 300,
center_y: float = 300,
radius: float = 120,
) -> list[dict]:
"""N개 원소를 원형으로 배치. 12시부터 시계방향."""
positions = []
for i in range(n):
angle = (2 * math.pi * i / n) - math.pi / 2
positions.append({
"cx": round(center_x + radius * math.cos(angle), 1),
"cy": round(center_y + radius * math.sin(angle), 1),
})
return positions
def calc_circle_radius(n: int, base_radius: int = 120) -> int:
"""N에 따라 작은 원 크기 자동 조정."""
if n <= 3: return base_radius
if n <= 5: return int(base_radius * 0.7)
return int(base_radius * 0.5)
```
**2. renderer.py 수정 지점**
```python
# render_multi_page() 또는 render_slide() 안에서:
if block_type in ("venn-diagram", "relationship"):
items = block_data.get("items", [])
if items:
from src.svg_calculator import calc_circle_positions, calc_circle_radius
positions = calc_circle_positions(len(items))
small_r = calc_circle_radius(len(items))
for i, item in enumerate(items):
item["cx"] = positions[i]["cx"]
item["cy"] = positions[i]["cy"]
item["r"] = small_r
```
**3. venn-diagram.html 수정**
```
현재: cx="265" (하드코딩)
변경: cx="{{ items[0].cx }}" (동적)
+ items 개수에 따라 for 루프로 생성
+ 큰 원 크기도 N에 따라 조정
```
### 충돌 검토
| 파일 | 영향 | 충돌? |
|------|------|-------|
| renderer.py | block_data 전처리 추가 | ⚠️ 주의: 기존 render 흐름에 if 분기 추가 |
| venn-diagram.html | 하드코딩 → 동적 좌표 | ⚠️ Phase 1 고정 SVG가 깨짐 → fallback 필요 |
| pipeline.py | 변경 없음 | ❌ |
| content_editor.py | items[].cx/cy는 편집자가 생성하지 않음 | ❌ (renderer에서 추가) |
### 회귀 위험
- **venn-diagram.html 변경 시 Phase 1 고정 SVG가 깨질 수 있음**
- 대응: items에 cx/cy가 없으면 기존 하드코딩 좌표 사용 (fallback)
### 대충 처리 위험
- ⚠️ 원 크기 자동 조정을 대충 하면 7개 원이 겹침
- 대응: N별 최적 반지름/큰원 크기 테이블 사전 정의
---
## Phase 2-C: Step A Opus+FAISS
### 현재 코드 상태
```
design_director.py line 145~178: select_preset()
→ 규칙 4줄: reference→sidebar, 대등비교→two-column, 고강조→hero, 나머지→single
→ LLM 호출 없음, 코드만
의도: Opus가 FAISS로 적합한 구조/블록 검색 + 배치/크기 결정
```
### 정확한 구현 방법
**1단계: select_preset()은 유지 (규칙 기반 프리셋은 안정적)**
**2단계: Opus가 블록 후보를 검색+선정하는 함수 추가**
```python
# design_director.py에 추가
async def _opus_block_selection(
content: str,
analysis: dict,
block_candidates: list[dict], # FAISS 검색 결과
) -> list[dict]:
"""Opus가 FAISS 후보에서 최종 블록을 선정하고 배치를 결정."""
# Kei API를 통해 Opus 호출
kei_url = settings.kei_api_url
prompt = f"""
콘텐츠 분석 결과와 블록 후보를 보고,
각 꼭지에 가장 적합한 블록을 선택하고 배치를 결정해줘.
후보 블록: {block_candidates}
꼭지: {analysis['topics']}
"""
# Kei API 호출 (실장과 동일 패턴)
...
```
### 충돌 검토
| 파일 | 영향 | 충돌? |
|------|------|-------|
| design_director.py | select_preset() 유지 + _opus_block_selection() 추가 | ❌ (추가만) |
| kei_client.py | Kei API 호출 패턴 재사용 | ❌ (참조만) |
| pipeline.py | create_layout_concept() 인터페이스 동일 | ❌ |
### 회귀 위험
- ⚠️ Opus가 Kei API를 통해 호출되어야 하는데, **Sonnet을 직접 호출하면 안 됨**
- 대응: _call_kei_api() 패턴 그대로 복제. Anthropic 직접 호출 금지.
- ⚠️ Kei API 실패 시 fallback = 현재 규칙 기반 방식 (select_preset + Sonnet Step B)
### 대충 처리 위험
- ⚠️ "Opus 대신 Sonnet 직접 호출" → **절대 금지**. 3단계에서 이미 이 실수 했음.
- ⚠️ FAISS 없이 catalog 전문 넣기 → Phase 2-A가 선행 안 되면 의미 없음
- 대응: Phase 2-A 완료 후에만 시작
---
## Phase 2-D: 5단계 재검토 강화
### 현재 코드 상태
```
pipeline.py line 102~161: _review_balance()
→ Sonnet에게 블록별 데이터 양(글자수)만 전달
→ HTML 자체는 전달하지 않음
→ shrink/rewrite action이 실질적으로 no-op
pipeline.py line 164~193: _apply_adjustments()
→ expand만 동작 (char_guide * 1.5)
→ shrink: 조건 매칭 안 됨 (expand만 if 처리)
→ rewrite: 아예 동작 없음
```
### 정확한 구현 방법
**1. _review_balance 프롬프트 개선**
```python
# 현재: 블록별 데이터 양만
# 변경: 블록별 텍스트 길이 + 블록 타입 + zone + height_cost
block_summary = []
for block in blocks:
data_len = len(json.dumps(block.get("data", {}), ensure_ascii=False))
block_summary.append(
f" {block['area']}/{block['type']}: "
f"데이터 {data_len}자, height_cost={block.get('height_cost', '?')}"
)
```
**2. shrink/rewrite 구현**
```python
# _apply_adjustments 수정
for adj in adjustments:
action = adj.get("action", "")
if action == "expand":
# 현재 동작: char_guide * 1.5
...
elif action == "shrink":
# 신규: char_guide * 0.7
for key in block.get("char_guide", {}):
block["char_guide"][key] = int(block["char_guide"][key] * 0.7)
elif action == "rewrite":
# 신규: data를 비우고 재편집 유도
block.pop("data", None)
```
**3. 재조정 횟수 제한**
```python
MAX_ADJUSTMENTS = 2
for attempt in range(MAX_ADJUSTMENTS):
review = await _review_balance(...)
if not review or not review.get("needs_adjustment"):
break
layout_concept = await _apply_adjustments(...)
html = render_slide(layout_concept)
```
### 충돌 검토
| 파일 | 영향 | 충돌? |
|------|------|-------|
| pipeline.py | _review_balance, _apply_adjustments 수정 | ❌ (내부 함수만) |
| content_editor.py | fill_content() 재호출됨 | ⚠️ data가 비워진 블록 → _apply_defaults로 fallback |
| renderer.py | 변경 없음 | ❌ |
### 회귀 위험
- ⚠️ 재조정 루프가 무한 반복되면 API 비용 폭증
- 대응: MAX_ADJUSTMENTS = 2로 하드 제한
- ⚠️ fill_content 재호출 시 Kei API가 아닌 Sonnet으로 빠질 수 있음
- 대응: fill_content는 이미 Kei API 1순위로 수정됨 ✅
---
## Phase 2-E: 누락 기능
### E-1: Pillow 이미지 크기
**수정 지점:** design_director.py create_layout_concept() 내부
```python
# 콘텐츠에 이미지 경로가 있으면 크기 확인
from PIL import Image
for topic in analysis.get("topics", []):
if topic.get("content_type") == "image":
img_path = topic.get("image_path")
if img_path and Path(img_path).exists():
w, h = Image.open(img_path).size
topic["image_width"] = w
topic["image_height"] = h
topic["image_ratio"] = w / h # >1.2 가로, <0.8 세로
```
**충돌:** 없음 (analysis dict에 필드 추가만)
**회귀:** 없음 (이미지가 없으면 기존 흐름 그대로)
### E-2: details-block 연결
**수정 지점:** pipeline.py generate_slide() 내부
```python
# 실장이 detail_target=True로 판단한 꼭지를 details-block으로 변환
# 현재 "생략"으로 처리 → details-block으로 연결
```
**충돌:** design_director.py에서 detail_target 꼭지를 "생략"으로 처리 중 → 이것을 "details-block으로 배치"로 변경 필요
**회귀:** detail_target 로직이 변경되므로 기존 테스트 영향
---
## 전체 충돌 매트릭스
```
director editor renderer pipeline kei_client config
2-A FAISS 수정 - - - - -
2-B SVG - - 수정 - - -
2-C Opus 수정 - - - 참조 -
2-D 재검토 - 호출 - 수정 - -
2-E Pillow 수정 - - 수정 - -
```
**동시 수정 파일이 겹치는 경우:**
- design_director.py: 2-A + 2-C + 2-E → **순서대로 진행 (2-A 먼저)**
- pipeline.py: 2-D + 2-E → **독립적 함수라 병렬 가능**
---
## 절대 규칙 (모든 Phase 2 작업에 적용)
### 🔴 절대 금지
1. **단발성/하드코딩 금지** — 특정 상황만 해결하는 if문, 매직넘버, 고정값 절대 금지. 모든 구현은 N개, M종류에 범용으로 동작해야 한다.
2. **회귀 금지** — Phase 1에서 확정한 구조(catalog 매핑, 카테고리 경로, BF-9 grid 분리, Kei API 우선)를 절대 되돌리지 않는다.
3. **Opus 대신 Sonnet 직접 호출 금지** — Kei API가 필요한 곳에 anthropic.AsyncAnthropic 직접 호출로 대체하지 않는다. fallback은 fallback이지 기본 경로가 아니다.
4. **"일단 돌아가게" 금지** — 동작하지만 원래 설계와 다른 구현은 기술 부채다. 설계대로 구현하거나 설계를 먼저 변경한다.
### 자가 점검 질문 (구현 전 반드시 확인)
- [ ] 이 코드가 블록 100개가 되어도 동작하는가?
- [ ] 이 코드가 원소 7개가 되어도 동작하는가?
- [ ] 이 코드에 하드코딩된 값이 있는가? 있다면 설정/계산으로 대체 가능한가?
- [ ] Phase 1에서 확정한 인터페이스(catalog 매핑, grid 프리셋 분리)를 변경하는가?
- [ ] Kei API가 아닌 Sonnet을 직접 호출하는 코드가 있는가? (fallback 제외)
- [ ] 이 수정이 다른 모듈의 기존 동작을 깨뜨리는가?
---
## "대충 처리" 방지 체크리스트
| # | 위험 | 방지책 | 점검 방법 |
|---|------|-------|----------|
| 1 | Opus 대신 Sonnet 직접 호출 | Kei API 패턴만 사용 | `grep "AsyncAnthropic" src/*.py` → fallback 위치만 허용 |
| 2 | FAISS 없이 catalog 전문 유지 | _load_catalog() 교체 | FAISS 실패 시에만 fallback, 기본은 검색 |
| 3 | SVG 좌표를 하드코딩 | calc_circle_positions() 계산 | `grep "cx=\"[0-9]" templates/blocks/visuals/` → 0건이어야 함 |
| 4 | 재검토 루프 무한 반복 | MAX_ADJUSTMENTS = 2 | 코드에 상수 존재 확인 |
| 5 | shrink/rewrite 미구현 | 3개 action 모두 if 분기 | _apply_adjustments에서 action별 동작 확인 |
| 6 | 이미지 크기 하드코딩 | Pillow로 실측 | 고정 비율(예: 1.5) 사용 금지 |
| 7 | details-block "생략" 유지 | detail_target → details-block 배치 | design_director에서 "생략" 문자열 제거 확인 |
| 8 | 특정 블록 수에만 동작 | N개 범용 루프 | `for i in range(n)` 패턴 확인, `items[0]` 직접 인덱싱 금지 |
| 9 | 특정 프리셋에만 동작 | 모든 프리셋에서 테스트 | 4개 프리셋 × 테스트 콘텐츠 조합 |