Files
C.E.L_Slide_test2/IMPROVEMENT-PHASE-I.md
kyeongmin ffad1ba82a Phase I 실행 완료 + 프로세스 재설계 (Stage 2.5 → Stage 5)
Phase I: 전수 정합성 복구 + 넘침 처리 패러다임 전환 (14개 항목)
- I-14: SSE 유틸 공통 추출 (src/sse_utils.py 신규, 3개 파일 중복 제거)
- I-13: dead code 3건 삭제 (_call_anthropic_direct, _extract_sse_text x2) + import anthropic 제거
- I-1: STEP_B_PROMPT purpose 가이드 미존재 블록 3개 → 실존 블록 교체
- I-2: catalog.yaml not_for 13건 미존재 블록 참조 교체/제거
- I-12: BLOCK_SLOTS 주석 개수 수정 (cards 9, visuals 6, emphasis 10)
- I-10: INDEX.md 38개 동기화 (삭제된 8개 블록 행 제거)
- I-11: README.md 38개 동기화 (_legacy 제거, 트리/개수 정리)
- I-3: PURPOSE_FALLBACK 상수 + purpose 기반 미등록 블록 교체
- I-7: compare-pill-pair 단독 사용 금지 검증
- I-4: 38개 블록 전체에 slot_desc 추가
- I-5: 편집자 프롬프트에 slot_desc 전달 로직
- I-6: 제목 유사도 70% 초과 시 자동 교정
- I-9: 넘침 판단 Kei API 호출 (KEI_OVERFLOW_PROMPT, call_kei_overflow_judgment)
- I-8: 대형 콘텐츠 정보 Kei overflow 프롬프트에 포함

프로세스 재설계:
- Stage 2.5 제거 → Stage 5에서 Sonnet 감지 + Kei 판단 통합
- _review_balance() 확장: zone 예산 + overflow_detected action 추가
- Stage 5 루프에 Kei 넘침 판단 호출 통합
- _apply_adjustments()에 kei_trim/kei_restructure action 추가
- _build_overflow_context(), _convert_kei_judgment() 헬퍼 함수 추가
- DOWNGRADE_MAP은 Kei API 실패 시 비상용으로만 잔존

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 13:06:21 +09:00

769 lines
35 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 I: 전수 정합성 복구 + 넘침 처리 패러다임 전환 — 실행 상세 (v3 최종)
> 전수 검토에서 발견된 프롬프트 자기모순, 문서-코드 불일치, 코드 안전망 부족을 해결.
> **핵심 변경: 넘침 시 기계적 블록 교체(DOWNGRADE_MAP) → Kei 판단 호출로 전환.**
> 원칙: 하드코딩 금지. 범용 해결. 회귀 금지. persona_agent 수정 0건.
> Sonnet 신규 투입 0건. Kei API를 사용해야 하는 곳에 Sonnet 대체 절대 금지.
---
## 문제 진단 총괄
### 전수 검토에서 발<><EBB09C><EFBFBD>된 근본 원인
**실제 블록 수: 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: 정<><ECA095><EFBFBD>성 복구 — 미존재 블록 참조 차단
삭제된 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 검증] 미등<EBAFB8><EB93B1> 블록 '{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 직접 호출 절대 <20><>지.
"""
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건 확인. |