Files
C.E.L_Slide_test2/docs/history/PHASE2-TECH-REVIEW.md
kyeongmin c42e01f060 문서 정리: Phase 히스토리 md를 docs/history/로 이동 + 오래된 테스트/에셋 정리
- 루트의 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>
2026-04-13 10:56:23 +09:00

397 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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개 프리셋 × 테스트 콘텐츠 조합 |