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