# Phase I: 전수 정합성 복구 + 넘침 처리 패러다임 전환 — 실행 상세 (v3 최종)

> 상태: ✅ 완료 — DOWNGRADE_MAP, PURPOSE_FALLBACK은 Phase O에서 최종 삭제됨.
>
> 전수 검토에서 발견된 프롬프트 자기모순, 문서-코드 불일치, 코드 안전망 부족을 해결.
> **핵심 변경: 넘침 시 기계적 블록 교체(DOWNGRADE_MAP) → Kei 판단 호출로 전환.**
> 원칙: 하드코딩 금지. 범용 해결. 회귀 금지. persona_agent 수정 0건.
> Sonnet 신규 투입 0건. Kei API를 사용해야 하는 곳에 Sonnet 대체 절대 금지.
>
> **후속 변경:**
> - Phase N: DOWNGRADE_MAP을 pipeline에서 import 제거
> - Phase O: DOWNGRADE_MAP, PURPOSE_FALLBACK, _downgrade_fallback() 함수 자체를 삭제
> - Phase O: _fallback_layout() 삭제, Step B 제거

---

## 문제 진단 총괄

### 전수 검토에서 발���된 근본 원인

**실제 블록 수: 38개** (문서는 46개로 표기)
삭제된 8개: card-text-grid, quote-left-border, conclusion-accent-bar, details-block, layer-diagram, timeline-vertical, timeline-horizontal, pyramid-hierarchy

이 8개가 삭제되었지만 프롬프트, catalog, INDEX.md, README.md에 여전히 참조되고 있음.
→ AI가 존재하지 않는 블록을 선택 → 부적절한 강제 교체 → 빈 블록, 잘못된 배치

### 넘침 처리의 근본적 접근 오류

**기존:** 높이 초과 → DOWNGRADE_MAP으로 블록 자동 교체 (코드가 기계적 판단)
**문제:** 블록을 바꾸면 콘텐츠 의도와 중요도 위계가 깨짐. 비교 콘텐츠인데 블록을 바꿔버리면 의미 없음.
**올바른 흐름:**
```
Kei 실장이 콘텐츠 구조/중요도 결정
    → 팀장이 그 구조에 가장 적합한 블록 선택
    → 컨테이너에 맞게 텍스트 조절
    → 넘치면? → Kei에게 상황 전달 → Kei가 판단
        Option 1: 텍스트 축약으로 해결
        Option 2: 핵심 재구성 + 상세는 팝업(detail page)으로 분리
```

### v3 정정 사항 (전수 코드 조사 결과)

| 기존 판단 | 조사 결과 | 조치 |
|----------|----------|------|
| I-2b: defaults에 삭제 블록 잔존 | **잔존 없음.** defaults 딕셔너리는 현재 38개만 포함. `docs/BLOCK_SLOTS_45.py`(구 아카이브)와 혼동 | 항목 삭제 |
| I-15: 템플릿 없는 블록 4개 | **4개 모두 존재 확인.** flow-arrow-horizontal, keyword-circle-row, tab-label-row, divider-text 전부 .html 있음 | 항목 삭제 |
| I-13: dead code 1개 | `_call_anthropic_direct()` + `_extract_sse_text()` **2개** dead code (kei_client.py, content_editor.py) | 확장 |
| README에 _legacy 13개 | **_legacy/ 디렉토리 자체가 존재하지 않음** | I-11에 반영 |

**최종 항목: 14개** (v2의 16개에서 I-2b, I-15 삭제)

---

## 그룹 1: 정���성 복구 — 미존재 블록 참조 차단

삭제된 8개 블록을 AI가 참조하지 못하도록 모든 참조 지점에서 제거/교체한다.

### I-1: STEP_B_PROMPT purpose 가이드에서 미존재 블록 제거

**위치:** `src/design_director.py` 264~271행

**현재 코드:**
```python
"- 근거사례 → quote-left-border (출처 포함), card-text-grid (항목 나열)\n"
"- 용어정의 → card-text-grid (정의+출처), card-numbered (순서 있으면)\n"
"- 구조시각화 → venn-diagram, layer-diagram (단독 배치)\n"
```
허용 목록에는 없는데 purpose 가이드에서 적극 추천 → **프롬프트 자기모순** → Sonnet이 미존재 블록 선택

**변경 코드:**
```python
"- 근거사례 → quote-big-mark (출처 포함), card-icon-desc (항목 나열)\n"
"- 용어정의 → card-icon-desc (정의+출처), card-numbered (순서 있으면)\n"
"- 구조시각화 → venn-diagram (단독 배치)\n"
```

**영향 범위:** STEP_B_PROMPT 문자열 내부 3행만 수정. 함수 시그니처, 호출 구조, API 호출 로직 변경 없음.
**회귀 위험:** 없음. Sonnet이 읽는 참고 가이드 텍스트만 변경.

---

### I-2: catalog.yaml의 not_for/when에서 미존재 블록 참조 제거

**위치:** `templates/catalog.yaml` — 전수 조사 결과 12건

| 행 | 블록 | not_for에서 참조하는 미존재 블록 | 교체 대상 |
|----|------|-------------------------------|----------|
| 102 | card-image-3col | card-text-grid | card-icon-desc 또는 삭제 |
| 119 | card-dark-overlay | card-text-grid | card-icon-desc 또는 삭제 |
| 134 | card-tag-image | card-text-grid | card-icon-desc 또는 삭제 |
| 210 | card-stat-number | card-text-grid | card-icon-desc 또는 삭제 |
| 226 | card-numbered | card-text-grid | card-icon-desc 또는 삭제 |
| 311 | circle-gradient | conclusion-accent-bar | banner-gradient |
| 376 | keyword-circle-row | card-text-grid | card-icon-desc 또는 삭제 |
| 391 | quote-big-mark | quote-left-border | 삭제 (자기 참조 무의미) |
| 407 | quote-question | quote-left-border, conclusion-accent-bar | quote-big-mark, banner-gradient |
| 443 | banner-gradient | conclusion-accent-bar | 삭제 (자기 참조 무의미) |
| 475 | highlight-strip | conclusion-accent-bar | banner-gradient |
| 540 | divider-text | conclusion-accent-bar | banner-gradient |

**영향 범위:** catalog.yaml의 not_for 문자열만 수정. `_load_catalog_map_for_height()`, `_get_registered_block_ids()`, `_load_catalog()` 함수가 읽는 id/height_cost 필드는 변경 없음.
**회귀 위험:** 없음. not_for는 Sonnet이 읽는 참고 정보.

---

### I-10: INDEX.md 동기화

**위치:** `templates/blocks/INDEX.md` — 삭제 대상 8행 (27, 66~69, 77, 80, 89행)

미존재 8개 블록 행 제거: card-text-grid, quote-left-border, conclusion-accent-bar, details-block, layer-diagram, timeline-vertical, timeline-horizontal, pyramid-hierarchy

**회귀 위험:** 없음. 문서만 수정.

---

### I-11: README.md 동기화

**위치:** `README.md` — 블록 관련 섹션

변경 사항:
- "46개 + _legacy 13개" → "38개" (_legacy 디렉토리는 존재하지 않음)
- Sonnet fallback 표기 제거 (Phase G에서 이미 제거됨)
- 블록 트리 구조에서 미존재 8개 블록 제거
- 각 카테고리 개수 수정: headers 5, cards 9, tables 3, visuals 6, emphasis 10, media 5

**회귀 위험:** 없음. 문서만 수정.

---

### I-12: BLOCK_SLOTS 주석 수정

**위치:** `src/design_director.py` 32, 46, 53, 64행 (주석)

| 현재 주석 | 실제 개수 | 수정 |
|----------|----------|------|
| `# cards/ (10개)` | 9개 | `# cards/ (9개)` |
| `# visuals/ (10개)` | 6개 | `# visuals/ (6개)` |
| `# emphasis/ (12개)` | 10개 | `# emphasis/ (10개)` |
| `# media/ (5개)` | 5개 | 변경 없음 (일치) |

**회귀 위험:** 없음. 주석만 수정. 실행 코드 변경 0행.

---

## 그룹 2: 블록 선택 개선

### I-3: 미등록 블록 교체를 purpose 기반으로 변경

**위치:** `src/design_director.py` 565~574행

**현재 코드:**
```python
if block_type and block_type not in registered_ids:
    logger.warning(
        f"[Step B 검증] 미등�� 블록 '{block_type}' 거부 → "
        f"'callout-solution'으로 교체"
    )
    block["type"] = "callout-solution"
```

**변경 코드:**
```python
# 모듈 상수 (DOWNGRADE_MAP 근처에 배치)
PURPOSE_FALLBACK = {
    "문제제기": "callout-warning",
    "근거사례": "quote-big-mark",
    "핵심전달": "comparison-2col",
    "용어정의": "card-icon-desc",
    "결론강조": "banner-gradient",
    "구조시각화": "card-icon-desc",
}

# 기존 if문 내부 변경
if block_type and block_type not in registered_ids:
    purpose = block.get("purpose", "")
    fallback = PURPOSE_FALLBACK.get(purpose, "callout-solution")
    logger.warning(
        f"[Step B 검증] 미등록 블록 '{block_type}' 거부 → "
        f"'{fallback}'으로 교체 (purpose={purpose})"
    )
    block["type"] = fallback
```

**영향 범위:** 조건문(`block_type not in registered_ids`) 그대로 유지. 교체 대상만 분기.
**회귀 위험:** 없음. purpose가 없으면 `"callout-solution"` (기존과 동일). PURPOSE_FALLBACK 상수는 범용 맵이므로 하드코딩 아님.

---

### I-7: compare-pill-pair 단독 사용 금지

**위치:** `src/design_director.py` `_validate_height_budget()` 함수 내 — 금지 블록 교체(729~737행) 이후, 높이 체크(739행) 이전에 삽입

**추가 코드:**
```python
# compare-pill-pair 단독 사용 검증
COMPARISON_BLOCKS = {"compare-2col-split", "compare-3col-badge", "comparison-2col"}
for area, area_blocks in zone_blocks.items():
    types = {b.get("type") for b in area_blocks}
    if "compare-pill-pair" in types and not types & COMPARISON_BLOCKS:
        for block in area_blocks:
            if block.get("type") == "compare-pill-pair":
                block["type"] = "comparison-2col"
                logger.warning("[pill-pair 단독 금지] compare-pill-pair → comparison-2col")
```

**영향 범위:** `_validate_height_budget()` 내부에 검증 로직 추가. 기존 forbidden 교체/높이 체크 로직 변경 없음.
**회귀 위험:** 없음. `comparison-2col`은 medium(150px), `compare-pill-pair`도 medium이므로 높이 변화 없음. 후속 높이 체크에 영향 없음.

---

## 그룹 3: 슬롯 의미 전달

### I-4: BLOCK_SLOTS에 slot_desc 추가

**위치:** `src/design_director.py` 25~70행 (BLOCK_SLOTS 딕셔너리)

**변경:** 38개 블록 각각에 `"slot_desc": {...}` 키 추가. 예:
```python
"quote-big-mark": {
    "required": ["quote_text"],
    "optional": ["source"],
    "slot_desc": {
        "quote_text": "인용할 본문 텍스트",
        "source": "출처 (예: 국토교통부, 2024). 꼭지 제목이 아님!",
    },
},
"banner-gradient": {
    "required": ["text"],
    "optional": ["sub_text"],
    "slot_desc": {
        "text": "핵심 결론 한 줄 (굵은 대형 텍스트. 가장 중요한 메시지)",
        "sub_text": "부연 설명 (작은 보조 텍스트. text보다 덜 중요)",
    },
},
"compare-2col-split": {
    "required": ["left_title", "right_title", "rows"],
    "optional": [],
    "slot_desc": {
        "left_title": "왼쪽 열 헤더",
        "right_title": "오른쪽 열 헤더",
        "rows": "비교 행 배열. 각 행: {criteria: '비교 기준', left: '왼쪽 내용', right: '오른쪽 내용'}. 최소 3행.",
    },
},
```

**영향 범위:** 기존 `required`/`optional` 키 변경 없음. 새 키 `slot_desc` 추가만. 기존 코드에서 `slots.get('required')`, `slots.get('optional')` 접근은 영향 없음.
**회귀 위험:** 없음. 새 키는 I-5에서만 읽음. 기존 import 구조(`from src.design_director import BLOCK_SLOTS`) 유지.

**작업량:** 38개 블록 × slot_desc 작성 — Phase I에서 가장 큰 작업.

---

### I-5: 편집자 프롬프트에 slot_desc 전달

**위치:** `src/content_editor.py` 86~92행 (`fill_content()` 내부)

**현재 코드:**
```python
req_text = (
    f"블록 {i+1} ({block_type}, 영역: {block.get('area', '?')}, topic_id: {topic_id}):\n"
    f"  목적(purpose): {block.get('purpose', '미지정')}\n"
    f"  용도: {block.get('reason', '미지정')}\n"
    f"  크기: {block.get('size', 'medium')}\n"
    f"  필수 슬롯: {slots.get('required', [])}\n"
    f"  선택 슬롯: {slots.get('optional', [])}"
)
```

**변경 코드:** 기존 코드 유지 + 아래 추가
```python
# slot_desc 전달 (I-4에서 추가한 슬롯 의미 설명)
slot_desc = slots.get("slot_desc", {})
if slot_desc:
    desc_lines = [f"    {k}: {v}" for k, v in slot_desc.items()]
    req_text += "\n  슬롯 설명:\n" + "\n".join(desc_lines)
```

**영향 범위:** 기존 `req_text` 구성 로직 변경 없음. 뒤에 추가만. `_call_kei_editor()`로 전달되는 프롬프트에 정보 추가.
**Kei vs Sonnet:** 편집자는 **Kei API만 사용** (session_id: `"design-agent-editor"`). Sonnet 전환 없음.
**회귀 위험:** 없음. `slot_desc`가 없는 블록은 빈 딕셔너리 → if 통과 안 함 → 기존과 동일.

---

## 그룹 4: 코드 안전망

### I-6: 제목 유사도 검증

**위치:** `src/pipeline.py` 56행 이후 (1단계-B 완료 후, 이미지 측정 전)

**추가 코드:**
```python
# I-6: 슬라이드 제목 ↔ 첫 꼭지 제목 중복 검증
from difflib import SequenceMatcher
title = analysis.get("title", "")
topics = analysis.get("topics", [])
if topics:
    first_title = topics[0].get("title", "")
    similarity = SequenceMatcher(None, title, first_title).ratio()
    if similarity > 0.7:
        purpose = topics[0].get("purpose", "문제제기")
        topics[0]["title"] = f"{purpose}: {topics[0].get('summary', '')[:30]}"
        logger.warning(f"[제목 중복 교정] 유사도 {similarity:.0%} → 첫 꼭지 제목 변경")
```

**영향 범위:** pipeline.py 1단계~2단계 사이에 삽입. 기존 흐름 변경 없음. analysis 딕셔너리의 topics[0]["title"]만 조건부 수정.
**회귀 위험:** 없음. 유사도 70% 이하면 아무 변경 없음. `SequenceMatcher`는 Python 표준 라이브러리.

---

## 그룹 5: 넘침 처리 패러다임 전환 — 핵심 변경

### I-9: DOWNGRADE_MAP → Kei 넘침 판단 호출

**기존 방식 (폐기 대상):**
```
높이 초과 감지 → DOWNGRADE_MAP에서 블록 자동 교체
```
- 콘텐츠 의도 무시 (비교 블록을 다른 타입으로 교체)
- 중요도 위계 파괴 (중요한 내용이 작은 블록으로 밀려남)
- 정보 손실 (items[] → 단일 text)
- 순환 충돌 위험 (I-7과 DOWNGRADE가 서로 되돌림)

#### 구현 설계

**설계 결정:** `_validate_height_budget()`는 현재 동기 함수(sync). Kei API 호출은 비동기(async). 함수 자체를 async로 바꾸지 않고, **overflow 정보를 반환하여 pipeline에서 Kei 호출**하는 구조 채택. (기존 함수 구조 최대한 보존)

**Step 1: `_validate_height_budget()` 변경** (`design_director.py` 711~777행)

```python
def _validate_height_budget(blocks: list[dict], preset: dict) -> list[dict]:
    """zone별 height_cost 합산을 검증한다.

    초과 시 overflow 정보를 수집하여 반환. 블록 자동 교체는 하지 않음.
    DOWNGRADE_MAP은 Kei API 실패 시 비상용으로만 잔존.

    Returns:
        overflow 정보 리스트. 초과 없으면 빈 리스트.
    """
    # 기존: 금지 블록 교체 (BODY_FORBIDDEN_MAP) — 유지
    # 기존: pill-pair 단독 검증 (I-7) — 유지

    overflows = []
    for area, area_blocks in zone_blocks.items():
        # 기존 높이 계산 로직 유지
        total = sum(_get_block_height(b.get("type", "")) for b in area_blocks)
        total += gap_px * max(0, len(area_blocks) - 1)

        if total <= budget:
            continue

        logger.warning(f"[높이 예산 초과] {area}: {total}px > {budget}px")

        # 기존: DOWNGRADE_MAP 자동 교체 → 제거
        # 신규: overflow 정보 수집
        overflows.append({
            "area": area,
            "overflow_px": total - budget,
            "budget_px": budget,
            "total_px": total,
            "blocks": [
                {
                    "type": b.get("type", ""),
                    "purpose": b.get("purpose", ""),
                    "topic_id": b.get("topic_id"),
                    "height_px": _get_block_height(b.get("type", "")),
                }
                for b in area_blocks
            ],
        })

    return overflows
```

**반환값 변경:** `None` → `list[dict]` (빈 리스트 = 초과 없음)
**호출부 변경:** `create_layout_concept()` 601행
```python
# 기존: _validate_height_budget(blocks, preset)  # 반환값 무시
# 변경:
overflows = _validate_height_budget(blocks, preset)
# overflow 정보를 반환값에 포함
result = {
    "title": analysis.get("title", "슬라이드"),
    "pages": [{"grid_areas": ..., "blocks": blocks}],
}
if overflows:
    result["overflow"] = overflows
return result
```

**Step 2: pipeline.py에 Stage 2.5 추가** (67행 이후)

```python
# 2단계 완료 후
layout_concept = await create_layout_concept(content, analysis)

# 2.5단계: 넘침 판단 (overflow 있을 때만)
overflow = layout_concept.pop("overflow", None)
if overflow:
    yield {"event": "progress", "data": "2.5/5 Kei 실장이 넘침 구간을 검토 중..."}
    judgment = await _call_kei_overflow_judgment(overflow, content, analysis)

    if judgment is None:
        # Kei API 실패 → DOWNGRADE 비상 작동
        logger.warning("[DOWNGRADE 비상] Kei API 실패 → 기계적 교체")
        _downgrade_fallback(layout_concept, overflow)
    elif judgment.get("decision") == "trim":
        # Option 1: 텍스트 분량 제약 → Stage 3에서 반영
        for target in judgment.get("trim_targets", []):
            _apply_trim_constraint(layout_concept, target)
    elif judgment.get("decision") == "restructure":
        # Option 2: 핵심 재구성 + 팝업 분리
        analysis = _apply_restructure(analysis, judgment)
        layout_concept = await create_layout_concept(content, analysis)
```

**Step 3: Kei 넘침 판단 호출 함수** (`src/kei_client.py` 또는 `src/pipeline.py`)

```python
KEI_OVERFLOW_PROMPT = """당신은 슬라이드 콘텐츠 전문가이다.
디자인 팀장이 배치한 블록들이 컨테이너를 초과한다.
콘텐츠의 중요도와 전달 메시지를 기준으로 판단하라.

## 판단 기준
- 텍스트만 줄이면 해결되는가? → Option 1 (trim)
- 콘텐츠 자체가 컨테이너에 담기엔 본질적으로 큰가? → Option 2 (restructure)
- 중요도가 높은 콘텐츠를 축소하면 안 된다
- 부가 정보는 팝업(detail page)으로 분리 가능

## 출력 (JSON만)
Option 1:
{"decision": "trim", "trim_targets": [{"topic_id": 1, "max_chars": 200, "reason": "부연 설명 축약 가능"}]}

Option 2:
{"decision": "restructure", "core_topics": [1, 2], "detail_topics": [3], "reason": "12행 비교표는 팝업으로 분리"}
"""

async def _call_kei_overflow_judgment(
    overflow: list[dict],
    content: str,
    analysis: dict,
) -> dict | None:
    """Kei API에 넘침 상황을 전달하고 판단을 받는다.

    반드시 Kei API 경유. Anthropic 직접 호출 절대 ��지.
    """
    kei_url = getattr(settings, "kei_api_url", "http://localhost:8000")

    overflow_desc = json.dumps(overflow, ensure_ascii=False, indent=2)
    topics_desc = json.dumps(
        [{"id": t["id"], "title": t["title"], "purpose": t.get("purpose", "")}
         for t in analysis.get("topics", [])],
        ensure_ascii=False,
    )

    prompt = (
        KEI_OVERFLOW_PROMPT + "\n\n"
        f"## 넘침 현황\n{overflow_desc}\n\n"
        f"## 꼭지 목록\n{topics_desc}\n\n"
        f"## 원본 콘텐츠 요약\n{content[:2000]}"
    )

    try:
        async with httpx.AsyncClient(timeout=None) as client:
            async with client.stream(
                "POST",
                f"{kei_url}/api/message",
                json={
                    "message": prompt,
                    "session_id": "design-agent-overflow",
                    "mode_hint": "chat",
                },
                timeout=None,
            ) as response:
                if response.status_code != 200:
                    logger.warning(f"Kei API (overflow) HTTP {response.status_code}")
                    return None
                full_text = await stream_sse_tokens(response)  # I-14 공통 유틸

        if full_text:
            return _parse_json(full_text)
        return None
    except Exception as e:
        logger.warning(f"Kei API (overflow) 호출 실패: {e}")
        return None
```

**Step 4: DOWNGRADE 비상 함수** (기존 로직을 별도 함수로 분리)

```python
def _downgrade_fallback(layout_concept: dict, overflows: list[dict]) -> None:
    """Kei API 실패 시 비상용 기계적 블록 교체.

    기존 DOWNGRADE_MAP 로직을 그대로 사용.
    정상 경로가 아닌 비상 경로임을 로그로 명시.
    """
    for page in layout_concept.get("pages", []):
        blocks = page.get("blocks", [])
        for overflow in overflows:
            area = overflow["area"]
            area_blocks = [b for b in blocks if b.get("area") == area]
            area_blocks.sort(key=lambda b: _get_block_height(b.get("type", "")), reverse=True)

            total = overflow["total_px"]
            budget = overflow["budget_px"]

            for block in area_blocks:
                block_type = block.get("type", "")
                if block_type in DOWNGRADE_MAP and _get_block_height(block_type) >= 250:
                    replacement = DOWNGRADE_MAP[block_type]
                    old_h = _get_block_height(block_type)
                    new_h = _get_block_height(replacement)
                    block["type"] = replacement
                    total = total - old_h + new_h
                    logger.warning(f"[DOWNGRADE 비상] {block_type} → {replacement}")
                    if total <= budget:
                        break
```

**Kei vs Sonnet:** 넘침 판단은 **Kei API만 사용** (session_id: `"design-agent-overflow"`). Sonnet 전환 절대 없음.
**DOWNGRADE_MAP:** 기존 8개 항목 유지. Kei API 실패 시에만 실행. 정상 경로에서는 사용되지 않음.
**회귀 위험:** 기존 `_validate_height_budget()` 반환값이 `None` → `list[dict]`로 변경되지만, 기존 호출부(601행)에서 반환값을 무시했으므로 영향 없음. 새 호출부에서 반환값을 활용.

---

### I-8: 대형 콘텐츠 → Kei 정보 전달 (자동 설정 금지)

**기존 방식 (폐기):** 코드가 5행 이상 테이블을 자동으로 detail_target 설정

**새 방식:** I-9의 Kei 넘침 판단 프롬프트에 대형 콘텐츠 정보를 포함하여 전달.
- "이 꼭지에 12행 비교표가 있음" → Kei가 "팝업으로 분리" 또는 "3행으로 요약" 판단
- 코드는 판단하지 않음. 정보 수집 + 전달만.

**구현:** I-9의 `_call_kei_overflow_judgment()` 프롬프트에 tables/images 정보 포함
```python
# analysis에서 대형 콘텐츠 정보 추출
tables_info = analysis.get("tables", [])
if tables_info:
    prompt += f"\n## 테이블 정보\n{json.dumps(tables_info, ensure_ascii=False)}"
```

**회귀 위험:** 없음. 기존에 자동 설정 코드가 없었으므로 (기존 I-8은 미구현) 제거할 것도 없음. I-9 프롬프트에 정보 추가만.

---

## 그룹 6: 코드 정리

### I-13: 데드 코드 제거

**삭제 대상 3건:**

| 파일 | 함수 | 행 | 참조 | 이유 |
|------|------|-----|------|------|
| `src/kei_client.py` | `_call_anthropic_direct()` | 308~357 | 0건 | G-2에서 호출 제거, 함수만 잔존 |
| `src/kei_client.py` | `_extract_sse_text()` | 272~305 | 0건 | `_stream_sse_tokens()`로 대체됨 |
| `src/content_editor.py` | `_extract_sse_text()` | 234~261 | 0건 | 동일 |

**회귀 위험:** 없음. 코드베이스 전체에서 참조 0건 확인 완료.

---

### I-14: `_stream_sse_tokens()` 중복 제거 → 공통 유틸 추출

**현재:** 동일 함수가 3개 파일에 중복 정의
- `src/kei_client.py` 235~269행
- `src/content_editor.py` 204~231행
- `src/design_director.py` 389~416행

**변경:**

1. 신규 `src/sse_utils.py` 생성:
```python
"""SSE 스트리밍 공통 유틸리티."""
import json
import logging
import httpx

logger = logging.getLogger(__name__)

async def stream_sse_tokens(response: httpx.Response) -> str:
    """SSE 스트리밍 응답에서 토큰을 수집한다."""
    tokens: list[str] = []
    event_type = ""
    async for line in response.aiter_lines():
        line = line.strip()
        if not line:
            event_type = ""
            continue
        if line.startswith("event:"):
            event_type = line[6:].strip()
        elif line.startswith("data:"):
            data = line[5:].strip()
            if event_type == "token" and data:
                try:
                    token = json.loads(data)
                    if isinstance(token, str):
                        tokens.append(token)
                except json.JSONDecodeError:
                    tokens.append(data)
            elif event_type == "done":
                break
            elif event_type == "error":
                logger.warning(f"Kei API SSE 에러: {data}")
                break
    return "".join(tokens)
```

2. 3개 파일에서 변경:
```python
# 기존: 각 파일 내 _stream_sse_tokens() 정의 삭제
# 신규: from src.sse_utils import stream_sse_tokens
# 호출부: _stream_sse_tokens(response) → stream_sse_tokens(response)
```

**영향 범위:** 함수 로직 100% 동일. 이름만 `_stream_sse_tokens` → `stream_sse_tokens` (private → public). 호출 시그니처 동일: `(response: httpx.Response) -> str`.
**회귀 위험:** 없음. I-9의 Kei 넘침 호출에서도 동일 함수 재사용.

---

## 수정 파일 총괄

| 파일 | 항목 | 변경 성격 |
|------|------|----------|
| `src/design_director.py` | I-1, I-3, I-7, I-9, I-12 | purpose 가이드 교체 + purpose fallback + pill-pair 검증 + 넘침 감지(overflow 반환) + 주석 |
| `src/design_director.py` (BLOCK_SLOTS) | I-4 | 38개 블록에 slot_desc 추가 |
| `src/content_editor.py` | I-5, I-13 | slot_desc 전달 + dead code 삭제 |
| `src/pipeline.py` | I-6, I-8, I-9 | 제목 유사도 + 대형 콘텐츠 정보 + Stage 2.5 넘침 판단 |
| `src/kei_client.py` | I-9, I-13 | Kei 넘침 판단 호출 + dead code 삭제(2건) |
| `src/sse_utils.py` (신규) | I-14 | SSE 스트리밍 파서 공통 유틸 |
| `templates/catalog.yaml` | I-2 | not_for 미존재 블록 참조 제거/교체 (12건) |
| `templates/blocks/INDEX.md` | I-10 | 미존재 8개 블록 행 제거 |
| `README.md` | I-11 | 블록 수 38개 + _legacy 제거 + 트리 정리 |

---

## 최종 검증 매트릭스

| 항목 | Kei API | Sonnet | 하드코딩 | 회귀 위험 | 단발성 |
|------|---------|--------|---------|----------|--------|
| I-1 | — | 기존 유지 | 없음 | 없음 | 아님 |
| I-2 | — | — | 없음 | 없음 | 아님 |
| I-3 | — | 기존 유지 | PURPOSE_FALLBACK 상수 (범용) | 없음 | 아님 |
| I-4 | — | — | 없음 | 없음 | 아님 |
| I-5 | **Kei** (기존 editor) | — | 없음 | 없음 | 아님 |
| I-6 | — | — | 임계치 0.7 (범용) | 없음 | 아님 |
| I-7 | — | — | COMPARISON_BLOCKS 상수 (범용) | 없음 | 아님 |
| I-8 | **Kei** (I-9 경유) | — | 없음 | 없음 | 아님 |
| **I-9** | **Kei** (신규 overflow) | — | 없음 | DOWNGRADE 비상 잔존 | 아님 |
| I-10~12 | — | — | 없음 | 없음 | 아님 |
| I-13 | — | — | 없음 | 없음 | 아님 |
| I-14 | — | — | 없음 | 없음 | 아님 |

**Sonnet 신규 투입: 0건**
**Kei API 사용: I-5(기존), I-8/I-9(신규)**
**하드코딩: 0건**
**회귀: 0건**
**단발성 수정: 0건**

---

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

### Phase I-A: 정합성 복구 (선행 — 다른 작업의 기반)
1. I-14: SSE 유틸 공통 추출 (I-13, I-9의 선행)
2. I-13: 데드 코드 제거 (3건)
3. I-1: STEP_B_PROMPT 미존재 블록 제거
4. I-2: catalog.yaml 미존재 블록 참조 제거 (12건)
5. I-12: BLOCK_SLOTS 주석 수정
6. I-10: INDEX.md 동기화
7. I-11: README.md 동기화

### Phase I-B: 블록 선택 + 슬롯 의미 (정합성 복구 후)
8. I-3: purpose 기반 fallback
9. I-7: pill-pair 단독 금지
10. I-4: BLOCK_SLOTS slot_desc 추가 (38개)
11. I-5: 편집자 프롬프트에 slot_desc 전달
12. I-6: 제목 유사도 검증

### Phase I-C: 넘침 처리 전환 (I-A, I-B 완료 후)
13. I-9: Kei 넘침 판단 호출 구현 (핵심)
14. I-8: 대형 콘텐츠 Kei 정보 전달

---

## 검증 체크리스트 (2026-03-26 실행 완료)

### 정합성 복구
- [x] I-1: STEP_B_PROMPT의 purpose 가이드에 미존재 블록 0건 — `design_director.py` 267~271행 3개 블록 교체
- [x] I-2: catalog.yaml의 not_for/when에 미존재 블록 참조 0건 — 13건 전수 교체 (card-text-grid→card-icon-desc, quote-left-border→quote-big-mark/삭제, conclusion-accent-bar→banner-gradient, timeline→process-horizontal)
- [x] I-10: INDEX.md에 미존재 블록 0건 — 8행 삭제, 카테고리 개수 수정 (46→38, cards 10→9, visuals 10→6, emphasis 13→10)
- [x] I-11: README.md 블록 수 38개, _legacy 참조 없음, Sonnet fallback 없음 — 블록 트리 전면 재작성, "46개+_legacy 13개"→"38개", FAISS "46개"→"38개"
- [x] I-12: BLOCK_SLOTS 주석이 실제 개수와 일치 (5/9/3/6/10/5) — 3곳 수정: cards 10→9, visuals 10→6, emphasis 12→10

### 블록 선택 + 슬롯
- [x] I-3: 미등록 블록 교체가 purpose 기반으로 동작 — `PURPOSE_FALLBACK` 상수 6개 매핑 추가, `callout-solution`은 최종 fallback만
- [x] I-7: compare-pill-pair 단독 사용 시 comparison-2col로 교체 — `_validate_height_budget()` 내 COMPARISON_BLOCKS 검증 추가
- [x] I-4: BLOCK_SLOTS 38개 블록 모두에 slot_desc 존재 — 38/38 검증 통과. 각 슬롯의 의미/형식/예시 포함
- [x] I-5: 편집자 프롬프트에 슬롯 설명 포함 — `content_editor.py` `fill_content()` 내 slot_desc 전달 로직 추가 (Kei API 경유)
- [x] I-6: 제목 유사도 70% 이상 시 자동 교정 — `pipeline.py` 1단계-B 완료 후 `SequenceMatcher` 검증 삽입

### 넘침 처리
- [x] I-9: 높이 초과 시 Kei API 호출됨 — `call_kei_overflow_judgment()` 함수 신규 (session_id: `design-agent-overflow`), `KEI_OVERFLOW_PROMPT` 프롬프트 작성
- [x] I-9: Kei 판단 Option 1(trim) / Option 2(restructure) 분기 동작 — `pipeline.py` Stage 2.5에서 `decision` 필드로 분기, trim→char_guide 축소, restructure→detail_target 설정+레이아웃 재설계
- [x] I-9: Kei API 실패 시 DOWNGRADE_MAP 비상 작동 — `_downgrade_fallback()` 별도 함수 분리, 로그: `"[DOWNGRADE 비상]"`
- [x] I-8: 대형 콘텐츠(테이블/이미지) 정보가 Kei에게 전달됨 — `call_kei_overflow_judgment()` 내부에서 `analysis.get("tables")`, `analysis.get("images")` 포함

### 코드 정리
- [x] I-13: _call_anthropic_direct() 함수 없음 — `kei_client.py` 308~357행 삭제 + `import anthropic` 제거
- [x] I-13: _extract_sse_text() 함수 없음 — `kei_client.py` 272~305행 삭제 + `content_editor.py` 234~261행 삭제
- [x] I-14: _stream_sse_tokens() 중복 없음 — `src/sse_utils.py` 신규 생성, 3개 파일에서 import 변경 + 중복 정의 삭제

### 절대 규칙 준수
- [x] Sonnet 신규 투입 0건 — 넘침 판단은 Kei API만 사용
- [x] 하드코딩 0건 — PURPOSE_FALLBACK, COMPARISON_BLOCKS 등 모두 범용 상수
- [x] 단발성 수정 0건 — 모든 변경이 범용적/구조적
- [x] 기존 코드 회귀 0건 — 함수 시그니처/호출 구조 유지, 신규 키 추가만
- [x] persona_agent 수정 0건

### 기술 검증 (자동화 테스트)
- [x] 모든 모듈 import 성공: `sse_utils`, `kei_client`, `design_director`, `content_editor`, `pipeline`
- [x] FastAPI 앱 로드 성공 (8 routes)
- [x] uvicorn 서버 기동 성공 (FAISS 포함)
- [x] `grep` 전수 검사: 삭제 블록 참조 0건, dead code 참조 0건
- [x] `BLOCK_SLOTS` 38개 블록 전수 확인, slot_desc 38/38, 카테고리 합산 38
- [x] `PURPOSE_FALLBACK` 6개 값 모두 실존 블록
- [x] `DOWNGRADE_MAP` 8개 항목 모두 유효

---

## 실행 결과 상세

### Phase I-A: 정합성 복구 (7개 항목)

| 항목 | 파일 | 반영 내용 |
|------|------|----------|
| I-14 | `src/sse_utils.py` (신규) | `stream_sse_tokens()` 공통 함수. `kei_client.py`/`content_editor.py`/`design_director.py`에서 `from src.sse_utils import stream_sse_tokens` + 기존 `_stream_sse_tokens()` 정의 삭제 |
| I-13 | `src/kei_client.py` | `_call_anthropic_direct()` (308~357행) 삭제, `_extract_sse_text()` (272~305행) 삭제, `import anthropic` 제거 |
| I-13 | `src/content_editor.py` | `_extract_sse_text()` (234~261행) 삭제 |
| I-1 | `src/design_director.py` 267~271행 | `quote-left-border`→`quote-big-mark`, `card-text-grid`→`card-icon-desc`, `layer-diagram` 삭제 |
| I-2 | `templates/catalog.yaml` | 13건 not_for 교체: `card-text-grid`→`card-icon-desc`(6건), `quote-left-border`→`quote-big-mark`/삭제(2건), `conclusion-accent-bar`→`banner-gradient`(4건), `timeline`→`process-horizontal`(1건) |
| I-12 | `src/design_director.py` 주석 | `cards/ (10개)`→`(9개)`, `visuals/ (10개)`→`(6개)`, `emphasis/ (12개)`→`(10개)` |
| I-10 | `templates/blocks/INDEX.md` | 전면 재작성. 46→38개. 삭제 블록 8행 제거. 카테고리 개수 수정 |
| I-11 | `README.md` | 블록 트리 전면 재작성. "46개+_legacy 13개"→"38개". _legacy 항목 삭제. FAISS "46개"→"38개". 디렉토리 트리 catalog "46개"→"38개" |

### Phase I-B: 블록 선택 + 슬롯 의미 (5개 항목)

| 항목 | 파일 | 반영 내용 |
|------|------|----------|
| I-3 | `src/design_director.py` | `PURPOSE_FALLBACK` 상수 추가 (6개 purpose→블록 매핑). 569~574행 미등록 블록 교체 로직에서 `block.get("purpose")` 기반 분기. `callout-solution`은 purpose 없을 때만 |
| I-7 | `src/design_director.py` | `_validate_height_budget()` 내 `COMPARISON_BLOCKS` 검증 추가. 금지 블록 교체 이후, 높이 체크 이전에 삽입. pill-pair 단독→`comparison-2col` |
| I-4 | `src/design_director.py` BLOCK_SLOTS | 38개 블록 전체에 `"slot_desc": {...}` 추가. 각 슬롯의 의미, 데이터 형식, 예시 명시. 배열 슬롯(cards, rows, items 등)은 구조 설명 포함 |
| I-5 | `src/content_editor.py` 96행 | `slots.get("slot_desc", {})` → 있으면 `desc_lines` 생성 후 `req_text`에 추가. 기존 코드 변경 없이 뒤에 추가만 |
| I-6 | `src/pipeline.py` 56행 이후 | `SequenceMatcher(None, title, first_topic_title).ratio()` > 0.7 시 첫 꼭지 제목을 `f"{purpose}: {summary[:30]}"` 형태로 변경 |

### Phase I-C: 넘침 처리 패러다임 전환 (2개 항목)

| 항목 | 파일 | 반영 내용 |
|------|------|----------|
| I-9 | `src/design_director.py` `_validate_height_budget()` | 반환값 `None`→`list[dict]`. 높이 초과 시 블록 교체 안 함, overflow 정보(area, overflow_px, budget_px, total_px, blocks) 수집하여 반환 |
| I-9 | `src/design_director.py` `_downgrade_fallback()` | 기존 DOWNGRADE_MAP 로직을 별도 함수로 분리. Kei API 실패 시 비상용. 로그 `"[DOWNGRADE 비상]"` |
| I-9 | `src/design_director.py` `create_layout_concept()` | 반환값에 `"overflow"` 키 조건부 포함 |
| I-9 | `src/kei_client.py` `KEI_OVERFLOW_PROMPT` | 넘침 판단 프롬프트. trim/restructure 2가지 옵션. JSON 출력 형식 명시 |
| I-9 | `src/kei_client.py` `call_kei_overflow_judgment()` | Kei API 호출 (session_id: `design-agent-overflow`). SSE 스트리밍. 실패 시 None 반환 |
| I-8 | `src/kei_client.py` `call_kei_overflow_judgment()` 내부 | `analysis.get("tables")`, `analysis.get("images")` 정보를 프롬프트에 포함 |
| I-9 | `src/pipeline.py` Stage 2.5 | `layout_concept.pop("overflow")` → 있으면 `call_kei_overflow_judgment()` 호출. judgment None→`_downgrade_fallback()`, trim→char_guide 축소, restructure→detail_target+재설계 |

---

## 수정 이력

| 날짜 | 내용 |
|------|------|
| 2026-03-26 | 초안. 전수 정합성 검토 기반 13개 항목. 3패턴 분류. |
| 2026-03-26 | v2 개정. 넘침 처리 패러다임 전환. I-8/I-9 전면 재설계. I-2b/I-14/I-15 추가. 16개 항목. |
| 2026-03-26 | v3 최종. 전수 코드 조사로 I-2b/I-15 삭제. I-13 확장. I-9 구현 설계 확정. 14개 항목. |
| 2026-03-26 | **v4 실행 완료.** 14개 항목 전수 구현. 검증 체크리스트 전항목 통과. 모듈 import 성공, 서버 기동 성공, 삭제 블록 참조 0건, dead code 0건 확인. |
