- 루트의 IMPROVEMENT-PHASE-*.md, PHASE-*.md 등 45개 → docs/history/로 이동 - docs/block-tests/ 오래된 블록 테스트 HTML 삭제 (figma_to_html_agent로 대체) - docs/figma-analysis/, docs/figma-assets/, docs/figma-screenshots/ 정리 - docs/test-*.html 등 초기 테스트 파일 정리 - 참고 페이지/ 스크린샷 정리 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
397 lines
14 KiB
Markdown
397 lines
14 KiB
Markdown
# 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개 프리셋 × 테스트 콘텐츠 조합 |
|