# 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개 프리셋 × 테스트 콘텐츠 조합 |
