# Phase N: 4대 핵심 문제 진단 + 해결 방안

> 작성일: 2026-03-27
> 상태: ✅ 완료 — catalog 개선, fallback 전면 제거, topic_id 버그 수정, 무한 재시도 체계 구축

---

## 오답 노트 (절대 반복 금지)

아래는 이미 실패가 증명된 접근법이다. **어떤 상황에서도 다시 사용하지 않는다.**

| # | 실패 패턴 | 왜 실패했나 | 교훈 |
|---|----------|-----------|------|
| X-1 | Sonnet에게 블록 선택을 맡김 | Kei 추천을 무시하고 자기 맘대로 바꿈. 프롬프트로 제어 불가 | 블록 선택은 Kei 권한. 코드 레벨 강제. |
| X-2 | Sonnet fallback (Kei 실패 시 Sonnet 대체) | Sonnet이 대체해봤자 품질이 안 나옴. 결과물이 무의미 | Kei API는 필수 인프라. 실패 시 파이프라인 중단. fallback 자체가 없음. |
| X-3 | max-height + overflow:hidden으로 CSS 사후 자르기 | 텍스트가 잘리는데 측정기가 "정상"이라고 판단. 근본적 결함 | 콘텐츠는 렌더링 전에 맞춰야 함. CSS로 사후에 자르지 않음. |
| X-4 | HTML 텍스트를 읽고 시각 검수 | Kei가 HTML 소스를 읽어봤자 렌더링 결과를 알 수 없음. 10분 낭비 | 시각 검수는 스크린샷(이미지)으로. |
| X-5 | "안전망/fallback"이라는 명목으로 실패 패턴 재도입 | 실패한 방법을 "비상용"이라고 다시 넣으면 결국 그게 돌아감 | 실패한 것은 비상용으로도 안 됨. 오답 노트에 기록하고 근절. |
| X-6 | 프롬프트만으로 LLM 행동 강제 | "반드시 존중하라"고 써도 LLM은 안 지킴 | 강제는 코드로. 프롬프트는 가이드일 뿐. |

---

## 문제 전체 요약

| # | 문제 | 원인 위치 | 심각도 |
|---|------|----------|--------|
| N-1 | 블록 선택이 콘텐츠 전달 방식과 안 맞음 | `design_director.py` Step B | **치명** |
| N-2 | 사이드바에 섹션 제목이 없음 | `kei_client.py` + `renderer.py` | 중간 |
| N-3 | max-height CSS가 콘텐츠를 잘라먹음 | `renderer.py` 229-235행 | **치명** |
| N-4 | Stage 5가 HTML 텍스트를 읽어서 무용지물 | `kei_client.py` + `pipeline.py` | **치명** |

---

## N-1. 블록 선택이 콘텐츠 전달 방식과 안 맞음

### 현상
- Kei 실장(Opus)이 1단계에서 `expression_hint`, `relation_type`을 판단함
- 2단계 Step A-2에서 Kei가 블록을 추천함 (`_opus_block_recommendation()`)
- **그런데 Step B에서 Sonnet이 Kei 추천을 무시하고 자기 맘대로 블록을 바꿈**
- 프롬프트에 "Opus 추천 존중" 규칙을 넣어도 Sonnet이 안 지킴

### 원인 (코드 레벨)

**`design_director.py` — Step B 흐름:**
```
Step A: rule-based preset 선택 (sidebar-right 등)
Step A-2: Kei API로 블록 추천 받음 → opus_blocks[]
Step B: Sonnet이 zone 배치 + char_guide 결정
         ↑ 여기서 Sonnet이 블록 타입을 바꿔버림
```

`STEP_B_PROMPT`에 "Opus가 추천한 블록을 존중하라"고 적어놨지만, **프롬프트는 강제가 아니다.**
Sonnet은 "더 적절하다"고 판단하면 얼마든지 다른 블록을 선택한다.

### 해결 방안: Kei가 블록을 결정, Sonnet은 zone + char_guide만

**핵심 원칙:** 블록 선택 = Kei 권한. 코드 레벨 강제. 프롬프트 의존 안 함.

**변경 대상:** `design_director.py`

```
현재 흐름:
  Step A: preset 선택
  Step A-2: Kei 블록 추천 (참고용)
  Step B: Sonnet이 블록 + zone + char_guide 전부 결정

변경 후:
  Step A: preset 선택
  Step A-2: Kei가 블록 확정 (topic_id → block_type 매핑)
  Step B: Sonnet은 zone 배치 + char_guide만 결정 (block_type 변경 금지)
```

**구체적 변경:**

1. **Step A-2 (`_opus_block_recommendation`)**: Kei API 응답에서 받은 블록을 "추천"이 아닌 "확정"으로 처리
   - 반환값: `{topic_id: block_type}` 딕셔너리
   - 이 딕셔너리를 Step B에 **읽기 전용**으로 전달

2. **Step B 프롬프트 변경**: `STEP_B_PROMPT`에서 블록 선택 지시 제거
   - "각 꼭지에 맞는 블록을 선택하라" → 삭제
   - "아래 확정된 블록의 zone 배치와 글자 수 가이드만 결정하라"로 변경

3. **Step B 후처리 (코드 강제)**:
   ```python
   # Sonnet 응답 후, 블록 타입을 Kei 확정값으로 덮어쓰기
   for block in sonnet_blocks:
       tid = block.get("topic_id")
       if tid in kei_confirmed_blocks:
           block["type"] = kei_confirmed_blocks[tid]  # 코드 레벨 강제
   ```
   - Sonnet이 어떤 블록을 응답하든, topic_id에 매칭되는 Kei 확정 블록으로 강제 교체
   - Sonnet의 zone, char_guide, reason만 살림

4. **Kei API는 필수 의존성:** 실패 시 fallback 없음. 파이프라인 중단 + 에러 반환.
   - Kei API(localhost:8000)는 항상 떠 있어야 하는 로컬 인프라
   - 안 되면 그건 버그. 대체 경로가 아니라 수정 대상.

**사용 기술:**
- 기존 Kei API (`_opus_block_recommendation`) — 이미 존재
- Python dict 매핑으로 코드 레벨 강제 — 새 도구 불필요
- `STEP_B_PROMPT` 프롬프트 축소 — zone + char_guide만

---

## N-2. 사이드바에 섹션 제목이 없음

### 현상
- 사이드바에 "용어 정의" 같은 콘텐츠가 배치되는데
- 그게 뭔지 알려주는 섹션 제목이 없음
- 독자가 사이드바가 무엇인지 맥락을 모름

### 원인 (코드 레벨)

1. **Kei 1단계 (`KEI_PROMPT`)**: `role: "reference"` + `purpose: "용어정의"`는 출력하지만, **section_title** 필드가 없음
2. **design_director.py Step B**: sidebar zone에 블록을 배치할 때 섹션 제목 블록을 안 넣음
3. **renderer.py**: area div를 렌더할 때 영역 라벨 없이 바로 블록 HTML만 출력

### 해결 방안: Kei가 section_title 판단 + 렌더러가 표시

**변경 대상:** `kei_client.py`, `design_director.py`, `renderer.py`

1. **Stage 1 Kei 프롬프트 (`KEI_PROMPT`) 확장:**
   - 기존 topic 필드에 `section_title` 추가
   - `role: "reference"`인 꼭지에 Kei가 "용어 정의", "참고 자료" 등 섹션 제목을 부여
   - 출력 JSON 예시:
     ```json
     {"id": 4, "title": "용어 혼용 정리", "purpose": "용어정의",
      "role": "reference", "section_title": "용어 정의"}
     ```

2. **Step B 블록 배치에 section label 블록 자동 삽입:**
   - sidebar zone에 reference 블록이 배치될 때
   - 해당 topic의 `section_title`이 있으면 → `topic-center` 또는 `divider-text` 블록을 자동 삽입
   - 이것은 **코드 레벨** (Sonnet 판단 아님)

3. **renderer.py `_group_blocks_by_area()`에서 sidebar 처리:**
   - sidebar area 그룹에 section label이 있으면 최상단에 배치
   - CSS: 작은 글씨 + 볼드 + 하단 구분선

**사용 기술:**
- KEI_PROMPT JSON 스키마 확장 (section_title 필드 1개)
- 기존 블록 (`divider-text` 또는 `topic-center`) 재활용
- renderer.py 코드 로직으로 자동 삽입

---

## N-3. max-height CSS가 콘텐츠를 잘라먹음

### 현상
- 렌더된 HTML에서 텍스트가 중간에 뚝 잘려 보임
- Selenium으로 측정하면 "overflow 없음"이라고 나옴 → 실제로는 잘리고 있는데 감지 못함
- 결과: Phase L 피드백 루프가 "정상"으로 판단하고 넘어감 → 잘린 채로 최종 출력

### 원인 (코드 레벨)

**`renderer.py` 229-235행:**
```python
# Phase L: 블록별 max-height 제약
max_height = block.get("_max_height_px")
if max_height:
    rendered_html = (
        f'<div style="max-height:{max_height}px; overflow:hidden;">'
        f'{rendered_html}</div>'
    )
```

이게 하는 일:
1. 블록에 `max-height: Npx; overflow: hidden` CSS를 씌움
2. → 콘텐츠가 N px을 넘으면 **시각적으로 잘림**
3. → `overflow: hidden`이므로 `scrollHeight === clientHeight` → **측정기가 "overflow 없음"으로 판단**
4. → 피드백 루프가 작동 안 함 → 잘린 채 확정

**근본 원인:** 텍스트가 공간에 맞는지를 CSS로 사후에 자르는 게 아니라, **편집 단계에서 글자 수를 맞춰야 한다.**

### 해결 방안: max-height 제거 + 편집자에게 _max_chars 강제 전달

**핵심 원칙:**
- 콘텐츠가 렌더링 전에 공간에 맞아야 한다 (fit before render)
- CSS로 사후에 자르지 않는다
- overflow는 측정으로 감지하고, 감지되면 편집자를 다시 호출한다

**변경 대상:** `renderer.py`, `content_editor.py`, `slide_measurer.py`

### 변경 1: renderer.py에서 max-height 래퍼 제거

```python
# 229-235행 삭제. 아래 코드 완전 제거:
max_height = block.get("_max_height_px")
if max_height:
    rendered_html = (
        f'<div style="max-height:{max_height}px; overflow:hidden;">'
        f'{rendered_html}</div>'
    )
```

max-height 없이 렌더링 → overflow가 생기면 `scrollHeight > clientHeight`로 정확히 감지됨.

### 변경 2: content_editor.py 프롬프트에 _max_chars 강제 명시

현재 `EDITOR_PROMPT`의 purpose별 분량 원칙이 "가이드라인" 수준.
`_max_chars`가 계산되어 있지만 편집자에게 전달이 안 되고 있음.

```python
# fill_content()에서 각 블록의 _max_chars를 프롬프트에 명시
req_text += f"\n  **최대 글자 수 (절대 제한): {block.get('_max_chars', '없음')}자**"
req_text += f"\n  이 글자 수를 넘기면 슬라이드에서 잘린다. 반드시 지켜라."
```

### 변경 3: slide_measurer.py의 overflow 감지 정상화

max-height + overflow:hidden이 없어지면, 기존 측정 스크립트가 정상 작동:
```javascript
// scrollHeight > clientHeight → 정확한 overflow 감지
overflowed: zone.scrollHeight > zone.clientHeight + 2
```

현재 `_MEASURE_SCRIPT`는 이미 이 로직을 갖고 있음. max-height만 제거하면 됨.

**추가: overflow:visible 확인**
- CSS에서 zone/block 컨테이너에 `overflow: hidden`이 없는지 확인
- `base.css`에 혹시 hidden이 있으면 제거
- 기본값 `overflow: visible`이면 scrollHeight 측정이 정확

### 변경 4: Phase L 피드백 루프 강화

현재 `pipeline.py` 215-275행의 피드백 루프:
1. 측정 → overflow 감지 → char_guide 축소 → 편집자 재호출 → 재렌더링
2. 최대 3회 반복

**수정사항:**
- char_guide 축소 대신 `_max_chars` 직접 축소 (더 정확)
- 축소량: `calculate_trim_chars(excess_px)` 결과를 `_max_chars`에서 차감
- 편집자 재호출 시 축소된 `_max_chars`를 프롬프트에 명시

**사용 기술:**
- 기존 Selenium + `scrollHeight > clientHeight` — 이미 존재, max-height만 제거하면 작동
- 기존 `calculate_max_chars()`, `calculate_trim_chars()` — 이미 존재
- `content_editor.py` 프롬프트 확장 — `_max_chars` 전달만 추가
- **새 도구 불필요**

---

## N-4. Stage 5가 HTML 텍스트를 읽어서 무용지물

### 현상
- Kei 실장이 최종 검수 (Stage 5)에서 10분 걸리는데 아무것도 안 바뀜
- 이유: Kei가 **HTML 소스 텍스트**를 읽고 검수함
- HTML 태그 사이에서 실제 렌더링 결과를 상상해야 함 → 불가능
- "텍스트가 잘리는지", "비중이 맞는지", "가독성이 괜찮은지" → HTML 텍스트로는 판단 불가

### 원인 (코드 레벨)

**`kei_client.py` `call_kei_final_review()` 306-313행:**
```python
prompt = (
    KEI_REVIEW_PROMPT + "\n\n"
    f"## 핵심 메시지\n{core_message}\n\n"
    ...
    f"\n\n## 조립 HTML (요약)\n{html[:3000]}\n\n"  # ← HTML 소스 텍스트 3000자
    f"위 결과물을 검수하고 조정이 필요한지 판단해. JSON만."
)
```

Kei(Opus)는 멀티모달 모델이라 이미지를 볼 수 있는데, **현재는 텍스트만 전달.**

### 해결 방안: Selenium 스크린샷 → Kei API에 이미지 전달

**핵심 원칙:**
- Stage 5에서 Kei가 **실제 렌더링된 슬라이드 스크린샷**을 보고 검수
- HTML 텍스트 읽기 → 이미지 보기로 전환
- overflow 없으면 Stage 5 건너뜀 (시간 절약)
- 최대 1회만 (현재 2회 → 1회)

### 기술 조사 결과

#### Selenium 스크린샷 → base64

```python
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.common.by import By

options = Options()
options.add_argument("--headless=new")
options.add_argument("--window-size=1280,720")
options.add_argument("--force-device-scale-factor=1")

driver = webdriver.Chrome(options=options)
driver.get(f"data:text/html;charset=utf-8,{encoded_html}")

# 슬라이드 요소만 정확히 캡처
slide = driver.find_element(By.CSS_SELECTOR, ".slide")
screenshot_b64 = slide.screenshot_as_base64  # str, 순수 base64
driver.quit()
```

**API 출처:** Selenium 4.x `WebElement.screenshot_as_base64` 프로퍼티
- 반환: `str` (순수 base64, data URI prefix 없음)
- 형식: PNG
- 해당 요소의 bounding box만 캡처 (전체 페이지가 아님)

#### Anthropic Claude API 이미지 전달 형식

```python
import anthropic

client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
response = await client.messages.create(
    model="claude-opus-4-0-20250514",  # Opus = 멀티모달 지원
    max_tokens=4096,
    messages=[{
        "role": "user",
        "content": [
            {
                "type": "image",
                "source": {
                    "type": "base64",
                    "media_type": "image/png",
                    "data": screenshot_b64,  # 순수 base64 문자열
                },
            },
            {
                "type": "text",
                "text": "이 슬라이드를 검수해줘. ...",
            },
        ],
    }],
)
```

**API 출처:** Anthropic 공식 Vision 문서
- 지원 모델: Claude Opus 4, Sonnet 4, Haiku 3.5 전부 멀티모달 지원
- 지원 포맷: PNG, JPEG, GIF, WebP
- 이미지 크기 제한: 최대 8000x8000px, 5MB/장
- 1280x720 슬라이드: ~1,229 토큰 (비용 미미)

#### 문제: 현재 Kei API(`/api/message`)는 이미지 미지원

**Kei persona_agent 조사 결과:**
- `ChatRequest` 모델: `message: str` (텍스트만)
- 이미지 필드 없음
- LLM 호출 시 messages를 `{"role": "user", "content": str}`로 전달

**필요한 변경 (Kei persona_agent 측):**

```python
# ChatRequest 확장 (persona_agent/backend/main.py)
class ChatRequest(BaseModel):
    session_id: str | None = None
    message: str
    image_data: str | None = None        # base64 이미지 (선택)
    image_media_type: str | None = None  # "image/png" 등 (선택)
```

- 4개 파일, ~50줄 변경
- 기존 텍스트 요청은 깨지지 않음 (image 필드는 optional)
- Anthropic SDK는 이미 이미지 content block 지원 → 그대로 전달만 하면 됨

### 전체 Stage 5 변경 흐름

```
현재:
  Phase L 측정 → Stage 5: Kei가 HTML 텍스트 3000자 읽기 → 조정

변경 후:
  Phase L 측정 → overflow 없으면 Stage 5 건너뜀 (시간 절약)
                → overflow 있으면:
                    1. Selenium으로 슬라이드 스크린샷 (base64 PNG)
                    2. 스크린샷 + 측정 데이터 → Kei API (이미지 포함)
                    3. Kei가 실제 렌더링 보고 판단 → 조정 지시
                    4. 최대 1회 (현재 2회에서 축소)
```

**변경 대상:**
- `kei_client.py`: `call_kei_final_review()`에 이미지 전달 추가
- `pipeline.py`: Stage 5에 스크린샷 촬영 + overflow 없으면 skip 로직
- `slide_measurer.py`: 스크린샷 캡처 함수 추가 (`capture_slide_screenshot()`)
- Kei persona_agent: ChatRequest에 image 필드 추가 (4파일 ~50줄)

**주의:** Kei persona_agent 코드를 수정해야 함 → 사용자 승인 필요

### 대안: Kei API 변경 없이 Anthropic 직접 호출

Kei API 수정이 부담스러우면, Stage 5만 Anthropic API 직접 호출 가능:
- `anthropic.AsyncAnthropic`으로 Opus 직접 호출
- Kei 페르소나 시스템 프롬프트를 `personas/kei.md`에서 로드하여 system으로 전달
- **단점:** Kei의 RAG/세션 컨텍스트를 못 씀
- **장점:** persona_agent 수정 없음

---

## 실행 순서 (의존 관계)

```
N-3 (max-height 제거)     ← 가장 먼저. 다른 것과 독립.
  │
  ├→ N-1 (블록 선택 강제)  ← N-3과 독립. 병렬 가능.
  │
  ├→ N-2 (사이드바 제목)   ← N-1 완료 후 (블록 확정 후 제목 삽입)
  │
  └→ N-4 (스크린샷 검수)   ← N-3 완료 필수 (overflow 감지 정상화 후)
```

**추천 순서:**
1. **N-3** — max-height 제거 + _max_chars 편집자 전달 (즉시, 가장 급함)
2. **N-1** — 블록 선택 코드 강제 (N-3과 병렬 가능)
3. **N-2** — 사이드바 섹션 제목 (N-1 후)
4. **N-4** — 스크린샷 기반 검수 (N-3 후, Kei API 수정 필요)

---

## 충돌 / 회귀 / 오류 검토

### 검토 방법
- 4개 변경의 모든 수정 대상 파일을 코드 레벨로 읽고 교차 검증
- `overflow: hidden` 전수 조사 (`.py`, `.css`, `.html` 전체)
- `_max_height_px`, `_max_chars` 참조 전수 조사
- 각 변경 간 의존 관계 + 실행 순서에서의 충돌 가능성 점검

---

### N-3 (max-height 제거) — 충돌 분석

**`overflow: hidden`이 존재하는 3개 레이어:**

| 위치 | 값 | 용도 | 건드리나 |
|------|-----|------|---------|
| `.slide` (base.css:16) | `overflow: hidden` | 1280x720 프레임 바깥 차단 | **유지 (건드리지 않음)** |
| `.slide > div` (base.css:76) | `overflow: visible` | area div (body, sidebar 등) | 이미 visible. 변경 불필요 |
| `renderer.py:229-235` | `max-height:Npx; overflow:hidden` | 블록별 래퍼 | **이것만 제거** |

**개별 블록 템플릿의 `overflow: hidden` (15개+):**
- `card-image-3col.html`, `card-dark-overlay.html`, `venn-diagram.html` 등
- 이것은 이미지/카드의 `border-radius` 잘림용
- **텍스트 clipping과 무관 → 건드리지 않음**

**Phase L 측정기 영향:**
- max-height 래퍼 제거 후, `scrollHeight`가 실제 콘텐츠 높이를 정확 반영
- `_MEASURE_SCRIPT`의 `block.scrollHeight > block.clientHeight` → **정상 작동**
- 이전에 false-negative(잘렸는데 감지 못함)이던 것이 정상 감지됨
- Phase L 루프가 더 자주 트리거될 수 있음 → **의도한 동작** (잘리는 걸 고치는 것)
- MAX_MEASURE_ROUNDS = 3이면 충분

**회귀 위험:** 없음. max-height 래퍼는 Phase L에서 추가된 것이고, 제거해도 기존 블록/CSS에 영향 없음.

---

### N-1 (블록 선택 강제) — 충돌 분석

**기존 Step B 후처리 체인 (design_director.py:819-850):**
```
현재: Sonnet 응답 → 미등록 블록 거부 → area명 검증 → conclusion→footer 강제
추가: Sonnet 응답 → ★Kei 확정 블록 덮어쓰기 → 미등록 블록 거부 → area명 검증 → conclusion→footer 강제
```

| 시나리오 | 처리 |
|----------|------|
| Kei가 추천한 블록이 catalog에 없음 | 바로 다음 단계에서 미등록 검증 → PURPOSE_FALLBACK 교체 |
| Kei가 추천한 블록이 sidebar 금지 | `_validate_height_budget()`의 SIDEBAR_FORBIDDEN_BLOCKS 체크 |
| Kei API 미응답 | **파이프라인 중단 + 에러 반환. fallback 없음.** Kei API는 필수 인프라. |

**N-3과의 관계:** 독립. N-1은 2단계, N-3은 4단계. 서로 다른 파이프라인 단계.

**회귀 위험:** 없음. 기존 검증 체인 위에 한 단계 추가할 뿐.

---

### N-2 (사이드바 제목) — 충돌 분석

| 시나리오 | 위험 | 대응 |
|----------|------|------|
| label 블록 추가 → sidebar 높이 예산 초과 | 낮음 (label ~30px, 예산 490px) | label 블록은 고정 30px, allocate 제외 |
| N-1 미완료 상태에서 실행 | Sonnet이 블록을 바꿔서 label 위치 엉뚱 | **실행 순서: N-1 먼저, N-2 나중** |
| `_group_blocks_by_area()` 호환 | flex-column 최상단에 자연 배치 | 호환 문제 없음 |

**회귀 위험:** 없음. 기존 로직에 label 삽입만 추가.

---

### N-4 (스크린샷 검수) — 충돌 분석

| 시나리오 | 위험 | 대응 |
|----------|------|------|
| N-3 미완료 → overflow 감지 부정확 | "overflow 없으면 skip" 판단이 틀림 | **실행 순서: N-3 먼저, N-4 나중** |
| Selenium 인스턴스 충돌 | Phase L에서 quit 후 Stage 5에서 새 생성 | 동시 사용 아님, 충돌 없음 |
| Kei persona_agent 미수정 | 이미지 전달 불가 | 대안: Anthropic 직접 호출 (persona 프롬프트 파일에서 로드) |
| MAX_REVIEW_ROUNDS 2→1 축소 | 기존보다 조정 기회 줄어듦 | 스크린샷 기반이라 1회로 충분 (텍스트 기반이라 2회 필요했던 것) |

**N-4 선행 조건 결정 필요:**
- **옵션 A:** Kei persona_agent 수정 (ChatRequest에 image 필드 추가, ~50줄)
  - 장점: Kei의 RAG + 세션 컨텍스트 활용 가능
  - 단점: persona_agent 코드 수정 필요
- **옵션 B:** Anthropic API 직접 호출 (persona_agent 수정 없이)
  - 장점: design_agent 내에서 완결
  - 단점: Kei의 RAG/세션 없음, 페르소나 프롬프트만 로드

**회귀 위험:** 없음. Stage 5가 기존에 거의 무의미했으므로 (아무것도 안 바뀜), 변경해도 기존 품질이 나빠질 수 없음.

---

### 상호 작용 매트릭스

|  | N-1 | N-2 | N-3 | N-4 |
|--|-----|-----|-----|-----|
| **N-1** | — | N-2가 N-1에 의존 | 독립 | 독립 |
| **N-2** | N-1 완료 후 실행 | — | 독립 | 독립 |
| **N-3** | 독립 | 독립 | — | N-4가 N-3에 의존 |
| **N-4** | 독립 | 독립 | N-3 완료 후 실행 | — |

**충돌 가능 조합: 없음.** 4개 변경이 모두 파이프라인의 서로 다른 단계를 수정하므로 교차 간섭 없음.

---

## 최종 실행 계획

### 실행 순서 (의존 관계 기반)

```
① N-3: max-height 래퍼 제거 + _max_chars 편집자 전달
   (독립, 즉시 실행 가능)

② N-1: 블록 선택 코드 강제
   (N-3과 독립, ①과 병렬 가능)

③ N-2: 사이드바 섹션 제목
   (②N-1 완료 후)

④ N-4: 스크린샷 기반 검수
   (①N-3 완료 후 + persona_agent 수정 또는 직접호출 결정 후)
```

### 각 항목별 변경 파일 + 예상 규모

| 항목 | 변경 파일 | 신규 코드 | 삭제 코드 | 프롬프트 변경 |
|------|----------|----------|----------|-------------|
| **N-3** | renderer.py, content_editor.py, pipeline.py | ~10줄 | ~7줄 | EDITOR_PROMPT에 _max_chars 절대제한 추가 |
| **N-1** | design_director.py | ~15줄 (후처리 강제) | ~0줄 | STEP_B_PROMPT에서 블록선택 지시 제거 |
| **N-2** | kei_client.py, design_director.py, renderer.py | ~20줄 | ~0줄 | KEI_PROMPT에 section_title 필드 추가 |
| **N-4** | slide_measurer.py, kei_client.py, pipeline.py + (persona_agent 4파일) | ~80줄 | ~10줄 | KEI_REVIEW_PROMPT을 이미지 기반으로 변경 |

### 오류 처리 원칙

**Kei API는 필수 인프라다. "실패하면 대체"가 아니라, 실패하면 파이프라인 중단이다.**

| 시나리오 | 처리 | 이유 |
|----------|------|------|
| Kei API 미응답 (N-1, N-2, N-3, N-4 공통) | **파이프라인 즉시 중단 + 에러 반환** | Kei는 선택이 아닌 필수. 없으면 돌리면 안 됨 |
| 편집자(Kei)가 _max_chars 안 지킴 (N-3) | Phase L 루프가 감지 → Kei 편집자 재호출 (최대 3회) | 측정 기반 재시도 |
| Selenium 스크린샷 실패 (N-4) | Stage 5를 텍스트 기반으로 수행 (현재 방식) | Selenium은 도구. 도구 실패 시 기존 방식 유지 |
| sidebar label이 높이 초과 유발 (N-2) | label을 고정 30px로 처리, allocate에서 제외 | 본문 블록 공간 유지 |

---

## 파일별 변경 범위 요약

| 파일 | N-1 | N-2 | N-3 | N-4 |
|------|-----|-----|-----|-----|
| `design_director.py` | Step B 프롬프트 축소 + 후처리 강제 | sidebar label 삽입 | - | - |
| `kei_client.py` | - | KEI_PROMPT section_title 추가 | - | 이미지 전달 추가 |
| `content_editor.py` | - | - | _max_chars 프롬프트 전달 | - |
| `renderer.py` | - | sidebar label 렌더 | max-height 래퍼 **삭제** | - |
| `pipeline.py` | - | - | Phase L 루프 _max_chars 축소 | Stage 5 스크린샷 + skip 로직 |
| `slide_measurer.py` | - | - | - | `capture_slide_screenshot()` 추가 |
| `space_allocator.py` | - | - | - | - |
| **Kei persona_agent** | - | - | - | ChatRequest 이미지 확장 (~50줄) |
