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>
This commit is contained in:
2026-03-26 13:06:21 +09:00
parent 1c65255f04
commit ffad1ba82a
11 changed files with 1982 additions and 535 deletions

View File

@@ -1,13 +1,15 @@
# Phase I: 전수 정합성 복구 + 10가지 런타임 문제 해결 — 실행 상세
# Phase I: 전수 정합성 복구 + 넘침 처리 패러다임 전환 — 실행 상세 (v3 최종)
> 전수 검토에서 발견된 프롬프트 자기모순, 문서-코드 불일치, 코드 안전망 부족을 해결.
> **핵심 변경: 넘침 시 기계적 블록 교체(DOWNGRADE_MAP) → Kei 판단 호출로 전환.**
> 원칙: 하드코딩 금지. 범용 해결. 회귀 금지. persona_agent 수정 0건.
> Sonnet 신규 투입 0건. Kei API를 사용해야 하는 곳에 Sonnet 대체 절대 금지.
---
## 문제 진단 총괄
### 전수 검토에서 발된 근본 원인
### 전수 검토에서 발<EFBFBD><EFBFBD><EFBFBD>된 근본 원인
**실제 블록 수: 38개** (문서는 46개로 표기)
삭제된 8개: card-text-grid, quote-left-border, conclusion-accent-bar, details-block, layer-diagram, timeline-vertical, timeline-horizontal, pyramid-hierarchy
@@ -15,58 +17,143 @@
이 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 삭제)
---
## 패턴 A: 프롬프트 자기모순 (문제 2, 7)
## 그룹 1: 정<><ECA095><EFBFBD>성 복구 — 미존재 블록 참조 차단
삭제된 8개 블록을 AI가 참조하지 못하도록 모든 참조 지점에서 제거/교체한다.
### I-1: STEP_B_PROMPT purpose 가이드에서 미존재 블록 제거
**현재 문제:**
```python
# design_director.py 264~271행
"- 근거사례 → quote-left-border, card-text-grid" 미존재!
"- 용어정의 → card-text-grid" 미존재!
"- 구조시각화 → venn-diagram, layer-diagram" layer-diagram 미존재!
```
**위치:** `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이 미존재 블록 선택
**수정:**
```
- 근거사례 → quote-big-mark (출처 포함), card-icon-desc (항목 나열)
- 용어정의 → card-icon-desc (정의+출처), card-numbered (순서 있으면)
- 구조시각화 → venn-diagram (단독 배치)
**변경 코드:**
```python
"- 근거사례 → quote-big-mark (출처 포함), card-icon-desc (항목 나열)\n"
"- 용어정의 → card-icon-desc (정의+출처), card-numbered (순서 있으면)\n"
"- 구조시각화 → venn-diagram (단독 배치)\n"
```
**수정 파일:** src/design_director.py (STEP_B_PROMPT)
**영향 범위:** STEP_B_PROMPT 문자열 내부 3행만 수정. 함수 시그니처, 호출 구조, API 호출 로직 변경 없음.
**회귀 위험:** 없음. Sonnet이 읽는 참고 가이드 텍스트만 변경.
---
### I-2: catalog.yaml의 not_for/when에서 미존재 블록 참조 제거
**현재 문제:**
- circle-gradient not_for: "topic-header" → 실제 이름은 topic-left-right/topic-center/topic-numbered
- circle-gradient not_for: "conclusion-accent-bar" → 미존재. 실제는 banner-gradient
- process-horizontal not_for: "timeline" → 미존재. 실제 없음 (삭제됨)
**위치:** `templates/catalog.yaml` — 전수 조사 결과 12건
**수정:** catalog.yaml 전체에서 미존재 블록 참조를 실존 블록으로 교체
| 행 | 블록 | 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 |
**수정 파일:** templates/catalog.yaml
**영향 범위:** 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
# design_director.py 574행
block["type"] = "callout-solution" # 모든 미등록 블록을 일괄 교체
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"
```
결론 꼭지의 미등록 블록도 callout-solution으로 교체 → 의미적으로 부적절
**수정:** purpose 기반 교체 맵
**변경 코드:**
```python
# 모듈 상수 (DOWNGRADE_MAP 근처에 배치)
PURPOSE_FALLBACK = {
"문제제기": "callout-warning",
"근거사례": "quote-big-mark",
@@ -75,26 +162,52 @@ PURPOSE_FALLBACK = {
"결론강조": "banner-gradient",
"구조시각화": "card-icon-desc",
}
# 미등록 블록 거부 시: block의 purpose를 보고 적절한 대체 블록 선택
fallback_type = PURPOSE_FALLBACK.get(block.get("purpose", ""), "callout-solution")
# 기존 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
```
**수정 파일:** src/design_director.py
**영향 범위:** 조건문(`block_type not in registered_ids`) 그대로 유지. 교체 대상만 분기.
**회귀 위험:** 없음. purpose가 없으면 `"callout-solution"` (기존과 동일). PURPOSE_FALLBACK 상수는 범용 맵이므로 하드코딩 아님.
---
## 패턴 B: 슬롯 의미 정보 미전달 (문제 3, 5, 9, 10)
### 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 추가
**현재 문제:**
편집자가 슬롯 이름만 받고 의미를 모름:
- source에 출처가 아닌 꼭지 제목을 넣음 (문제 3)
- text/sub_text 순서를 뒤집음 (문제 5)
- rows[][] 배열 구조를 채우지 못함 (문제 9)
- cards[] 배열 구조를 채우지 못함 (문제 10)
**위치:** `src/design_director.py` 25~70행 (BLOCK_SLOTS 딕셔너리)
**수정:** BLOCK_SLOTS에 slot_desc 필드 추가
**변경:** 38개 블록 각각에 `"slot_desc": {...}` 키 추가. 예:
```python
"quote-big-mark": {
"required": ["quote_text"],
@@ -123,148 +236,376 @@ fallback_type = PURPOSE_FALLBACK.get(block.get("purpose", ""), "callout-solution
},
```
**수정 범위:** 38개 블록 모두에 slot_desc 추가 (가장 큰 작업)
**영향 범위:** 기존 `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 전달
**현재:** content_editor.py 87~88행
**위치:** `src/content_editor.py` 86~92행 (`fill_content()` 내부)
**현재 코드:**
```python
f" 필수 슬롯: {slots.get('required', [])}\n"
f" 선택 슬롯: {slots.get('optional', [])}"
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
f" 필수 슬롯: {slots.get('required', [])}\n"
f" 선택 슬롯: {slots.get('optional', [])}\n"
# slot_desc가 있으면 각 슬롯의 의미 전달
# 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)
```
**수정 파일:** src/content_editor.py, src/design_director.py (BLOCK_SLOTS)
**영향 범위:** 기존 `req_text` 구성 로직 변경 없음. 뒤에 추가만. `_call_kei_editor()`로 전달되는 프롬프트에 정보 추가.
**Kei vs Sonnet:** 편집자는 **Kei API만 사용** (session_id: `"design-agent-editor"`). Sonnet 전환 없음.
**회귀 위험:** 없음. `slot_desc`가 없는 블록은 빈 딕셔너리 → if 통과 안 함 → 기존과 동일.
---
## 패턴 C: 코드 안전망 부족 (문제 1, 4, 6, 8)
## 그룹 4: 코드 안전망
### I-6: 제목 유사도 검증 (문제 1)
### I-6: 제목 유사도 검증
**현재:** 프롬프트에 "달라야 한다"고 지시했지만 코드 검증 없음
**위치:** `src/pipeline.py` 56행 이후 (1단계-B 완료 후, 이미지 측정 전)
**수정:** pipeline.py 또는 design_director.py에서 `analysis["title"]``analysis["topics"][0]["title"]` 비교
**추가 코드:**
```python
# I-6: 슬라이드 제목 ↔ 첫 꼭지 제목 중복 검증
from difflib import SequenceMatcher
title = analysis.get("title", "")
first_topic_title = analysis.get("topics", [{}])[0].get("title", "")
similarity = SequenceMatcher(None, title, first_topic_title).ratio()
if similarity > 0.7:
# 첫 꼭지 제목을 purpose 기반으로 변경
first_topic = analysis["topics"][0]
purpose = first_topic.get("purpose", "문제제기")
first_topic["title"] = f"{purpose}: {first_topic.get('summary', '')[:30]}"
logger.warning(f"[제목 중복 교정] 유사도 {similarity:.0%} → 첫 꼭지 제목 변경")
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%} → 첫 꼭지 제목 변경")
```
**수정 파일:** src/pipeline.py
### I-7: compare-pill-pair 단독 사용 금지 (문제 4)
**현재:** compare-pill-pair가 비교 내용 없이 라벨만 표시
**수정:** _validate_height_budget() 안에서 compare-pill-pair가 해당 zone에서 유일한 "비교" 블록이면 경고
```python
# compare-pill-pair는 비교 테이블의 헤더로만 사용
# 같은 zone에 compare-2col-split, compare-3col-badge, comparison-2col이 없으면 부적절
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:
# pill-pair를 comparison-2col로 교체
for block in area_blocks:
if block.get("type") == "compare-pill-pair":
block["type"] = "comparison-2col"
logger.warning(f"[pill-pair 단독 금지] compare-pill-pair → comparison-2col")
```
**수정 파일:** src/design_director.py
### I-8: 대형 테이블 → detail_target 자동 설정 (문제 6)
**현재:** 원본의 12행 비교표가 detail_target으로 잡히지 않음
**수정:** pipeline.py에서 1단계-B 완료 후
```python
for table in analysis.get("tables", []):
if table.get("rows", 0) >= 5 and not table.get("fits_single_page", True):
for topic in analysis.get("topics", []):
if topic.get("id") == table.get("topic_id"):
if not topic.get("detail_target"):
topic["detail_target"] = True
logger.info(f"대형 테이블 → detail_target 자동: topic {topic['id']}")
```
**수정 파일:** src/pipeline.py
### I-9: DOWNGRADE_MAP 확장 + 다단계 교체 (문제 8)
**현재:** body 높이 600px > 490px인데 medium 블록 교체 대상 없음
**수정:**
1. medium → compact 교체 맵 추가
2. 교체 임계치 `>= 250``>= 150`으로 낮춤
3. 1회 교체 후에도 초과이면 다음 블록도 교체 (현재는 break)
```python
DOWNGRADE_MAP = {
# 기존 large/xlarge → medium/compact
"venn-diagram": "card-icon-desc",
"card-step-vertical": "card-numbered",
...
# 추가: medium → compact
"quote-big-mark": "divider-text",
"comparison-2col": "compare-pill-pair", # 주의: I-7 규칙과 함께 적용
"process-horizontal": "flow-arrow-horizontal",
"dark-bullet-list": "highlight-strip",
"card-icon-desc": "highlight-strip",
}
```
**수정 파일:** src/design_director.py
**영향 범위:** pipeline.py 1단계~2단계 사이에 삽입. 기존 흐름 변경 없음. analysis 딕셔너리의 topics[0]["title"]만 조건부 수정.
**회귀 위험:** 없음. 유사도 70% 이하면 아무 변경 없음. `SequenceMatcher`는 Python 표준 라이브러리.
---
## 문서 동기화
## 그룹 5: 넘침 처리 패러다임 전환 — 핵심 변경
### I-10: INDEX.md 동기화 (38개 실제 현황)
### I-9: DOWNGRADE_MAP → Kei 넘침 판단 호출
미존재 8개 블록 항목 제거: card-text-grid, quote-left-border, conclusion-accent-bar, details-block, layer-diagram, timeline-vertical, timeline-horizontal, pyramid-hierarchy
**기존 방식 (폐기 대상):**
```
높이 초과 감지 → DOWNGRADE_MAP에서 블록 자동 교체
```
- 콘텐츠 의도 무시 (비교 블록을 다른 타입으로 교체)
- 중요도 위계 파괴 (중요한 내용이 작은 블록으로 밀려남)
- 정보 손실 (items[] → 단일 text)
- 순환 충돌 위험 (I-7과 DOWNGRADE가 서로 되돌림)
**수정 파일:** templates/blocks/INDEX.md
#### 구현 설계
### I-11: README.md 동기화
**설계 결정:** `_validate_height_budget()`는 현재 동기 함수(sync). Kei API 호출은 비동기(async). 함수 자체를 async로 바꾸지 않고, **overflow 정보를 반환하여 pipeline에서 Kei 호출**하는 구조 채택. (기존 함수 구조 최대한 보존)
- "46개 + _legacy 13개" → "38개"
- Sonnet fallback 표기 제거 (Phase G에서 제거됨)
- 블록 트리 구조에서 미존재 블록 제거
- 각 카테고리 개수 수정: cards 9, visuals 6, emphasis 10
**Step 1: `_validate_height_budget()` 변경** (`design_director.py` 711~777행)
**수정 파일:** README.md
```python
def _validate_height_budget(blocks: list[dict], preset: dict) -> list[dict]:
"""zone별 height_cost 합산을 검증한다.
### I-12: BLOCK_SLOTS 주석 수정
초과 시 overflow 정보를 수집하여 반환. 블록 자동 교체는 하지 않음.
DOWNGRADE_MAP은 Kei API 실패 시 비상용으로만 잔존.
- "visuals/ (10개)" → "visuals/ (6개)"
- "emphasis/ (12개)" → "emphasis/ (10개)"
Returns:
overflow 정보 리스트. 초과 없으면 빈 리스트.
"""
# 기존: 금지 블록 교체 (BODY_FORBIDDEN_MAP) — 유지
# 기존: pill-pair 단독 검증 (I-7) — 유지
**수정 파일:** src/design_director.py (주석만)
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)
### I-13: 데드 코드 정리
if total <= budget:
continue
- `_call_anthropic_direct()` 함수 제거 (kei_client.py) — G-2에서 호출 제거했지만 함수 자체는 잔존
logger.warning(f"[높이 예산 초과] {area}: {total}px > {budget}px")
**수정 파일:** src/kei_client.py
# 기존: 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 넘침 호출에서도 동일 함수 재사용.
---
@@ -272,32 +613,148 @@ DOWNGRADE_MAP = {
| 파일 | 항목 | 변경 성격 |
|------|------|----------|
| `src/design_director.py` | I-1, I-3, I-7, I-9, I-12 | purpose 가이드 교체 + 교체 맵 개선 + pill-pair 검증 + DOWNGRADE 확장 + 주석 |
| `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 | 편집자에게 slot_desc 전달 |
| `src/pipeline.py` | I-6, I-8 | 제목 유사도 + detail_target 자동 |
| `src/kei_client.py` | I-13 | 데드 코드 제거 |
| `templates/catalog.yaml` | I-2 | not_for 미존재 블록 참조 교체 |
| `templates/blocks/INDEX.md` | I-10 | 미존재 블록 제거 |
| `README.md` | I-11 | 블록 수 + fallback + 트리 구조 동기화 |
| `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 제거 + 트리 정리 |
---
## 검증 체크리스트
## 최종 검증 매트릭스
- [ ] I-1: STEP_B_PROMPT의 purpose 가이드에 미존재 블록 0건
- [ ] I-2: catalog.yaml의 not_for/when에 미존재 블록 참조 0건
- [ ] I-3: 미등록 블록 교체가 purpose 기반으로 동작
- [ ] I-4: BLOCK_SLOTS 38개 블록 모두에 slot_desc 존재
- [ ] I-5: 편집자 프롬프트에 슬롯 설명 포함
- [ ] I-6: 제목 유사도 70% 이상 시 자동 교정
- [ ] I-7: compare-pill-pair 단독 사용 시 comparison-2col로 교체
- [ ] I-8: 5행 이상 테이블 → detail_target 자동 true
- [ ] I-9: body 높이 초과 시 medium 블록도 교체 가능
- [ ] I-10: INDEX.md에 미존재 블록 0건
- [ ] I-11: README.md 블록 수 38개, Sonnet fallback 표기 없음
- [ ] I-12: BLOCK_SLOTS 주석이 실제 개수와 일치
- [ ] I-13: _call_anthropic_direct() 함수 없음
| 항목 | 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+재설계 |
---
@@ -305,4 +762,7 @@ DOWNGRADE_MAP = {
| 날짜 | 내용 |
|------|------|
| 2026-03-26 | 초안. 전수 정합성 검토 기반 13개 항목. 3패턴(프롬프트 모순 + 슬롯 의미 + 코드 안전망) 분류. |
| 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건 확인. |

View File

@@ -0,0 +1,464 @@
# 파이프라인 프로세스 재검토 — 검증 시점 문제 진단
> Phase I 실행 완료 후 실제 구동 중 발견된 프로세스 구조 문제.
> Phase I의 코드 변경(14개 항목)은 유효하나, **검증이 배치된 시점**이 부적절.
---
## 현재 프로세스 흐름 (as-is)
```
[1단계] Kei 실장 — 콘텐츠 분석 + 스토리라인 설계
├ 1-A: 꼭지 추출 (Kei API)
├ 1-B: 컨셉 구체화 (Kei API)
├ 제목 중복 검증 (코드)
└ 이미지 크기 측정 (Pillow)
[2단계] 디자인 팀장 — 레이아웃 + 블록 매핑
├ Step A: 프리셋 선택 (규칙 기반)
├ Step A-2: Opus 블록 추천 (Kei API)
├ Step B: Sonnet 블록 매핑
└ 블록 검증 (코드): 미등록 교체, zone 교정, pill-pair, 높이 예산 체크
[2.5단계] ⚠️ Kei 넘침 판단 — 예상 높이 기반
[3단계] Kei 편집자 — 텍스트 채움 (Kei API)
[4단계] 디자인 실무자 — CSS 조정 + HTML 렌더링 (Sonnet + Jinja2)
[5단계] 디자인 팀장 — 재검토 + 조정 루프 (Sonnet, 최대 2회)
미리보기 + HTML 다운로드
```
---
## 각 시점에서 알 수 있는 정보
| 시점 | 원본 텍스트 | 꼭지 분석 | 블록 배치 | 실제 텍스트 | 렌더링 HTML | 실제 높이 |
|------|:---------:|:--------:|:--------:|:---------:|:----------:|:--------:|
| 1단계 후 | O | O | - | - | - | - |
| 2단계 후 | O | O | O | - | - | 예상만 |
| 2.5단계 | O | O | O | **없음** | **없음** | 예상만 |
| 3단계 후 | O | O | O | **O** | - | - |
| 4단계 후 | O | O | O | O | **O** | 측정 가능 |
| 5단계 | O | O | O | O | O | 측정 가능 |
---
## 문제 진단 (6건)
### 문제 1: 내용 없이 넘침 판단
**위치:** Stage 2.5
**현상:** Kei에게 "이 zone이 넘친다"고 전달하지만, 실제 텍스트가 없는 상태. 블록 타입의 예상 높이(medium=150px, large=250px)만으로 판단 요청.
**문제:** Kei가 "trim할까 restructure할까"를 결정하려면 실제 콘텐츠를 봐야 하는데 볼 수 없음. 판단 근거가 부족한 상태에서 판단을 요청.
---
### 문제 2: 예상 높이 초과 → 판단 주체 잘못됨
**위치:** Stage 2.5
**현상:** Sonnet에게 이미 "zone 예산 490px, height_cost 확인해서 초과하지 마라"고 프롬프트로 지시함. 그런데도 예상 높이가 초과하면 그건 **Sonnet이 지시를 안 따른 것**.
**문제:** Sonnet의 지시 불이행을 Kei에게 물어볼 문제가 아님. Sonnet을 다시 호출하거나 프롬프트를 개선할 문제. 판단 주체와 해결 주체가 불일치.
---
### 문제 3: 실제 HTML이 있는데 넘침을 안 봄
**위치:** Stage 5
**현상:** 렌더링된 HTML이 있고, 각 블록의 실제 텍스트 양도 알 수 있는 시점. 그러나 현재 Stage 5의 점검 항목은 "빈 블록, 채움 불균형, 정보량, HTML 구조"만.
**문제:** 정작 "컨테이너에 실제로 넘치는가"는 점검 항목에 없음. 넘침을 확인할 수 있는 최적의 시점에서 확인하지 않음.
---
### 문제 4: 넘침 판단에 Kei가 없음
**위치:** Stage 5
**현상:** Stage 5 재검토는 Sonnet이 단독으로 수행. 조정도 expand/shrink/rewrite를 Sonnet이 결정.
**문제:** 넘침 발생 시 "뭘 줄이고 뭘 팝업으로 분리할지"는 **콘텐츠 중요도 판단** — Kei가 해야 할 일. 현재 Stage 5에 Kei 참여 경로가 없음.
---
### 문제 5: 실제 렌더링 높이 측정 수단 없음
**위치:** 전체 파이프라인
**현상:** 파이프라인 어디에서도 렌더링된 HTML의 실제 px 높이를 측정하지 않음.
- Stage 2: 블록 타입 기반 예상 높이 (HEIGHT_COST_PX: compact=70, medium=150, large=250, xlarge=400)
- Stage 5: Sonnet이 HTML 코드를 읽고 눈대중으로 판단
**문제:** 예상 높이와 실제 높이는 다를 수 있음. 텍스트 양, CSS 조정, 폰트 크기에 따라 실제 높이가 달라지는데 이를 측정하는 코드가 없음.
---
### 문제 6: 넘침이 재검토 루프에 포함 안 됨
**위치:** Stage 5 루프
**현상:** Stage 5는 `재검토 → 조정 → fill_content(Stage 3) → render(Stage 4) → 재검토` 루프가 있음 (최대 2회).
**문제:** 이 루프 안에 넘침 판단이 없음. 조정 후에도 여전히 넘칠 수 있는데, expand 조정으로 텍스트가 늘어나서 오히려 더 넘칠 수도 있음. 루프가 넘침을 감지하지 못함.
---
## 문제 요약 매트릭스
| # | 문제 | 위치 | 핵심 원인 | 영향 |
|---|------|------|----------|------|
| 1 | 내용 없이 넘침 판단 | 2.5 | 텍스트 채움 전에 판단 | Kei 판단 근거 부족 → 부정확한 결정 |
| 2 | 예상 높이 초과 → Kei에게 물음 | 2.5 | 판단 주체 잘못됨 | Sonnet 지시 불이행을 Kei가 해결할 수 없음 |
| 3 | HTML 있는데 넘침 안 봄 | 5 | 점검 항목 누락 | 실제 넘침 감지 못함 |
| 4 | 넘침 판단에 Kei 없음 | 5 | Sonnet만 참여 | 콘텐츠 중요도 무시한 조정 |
| 5 | 실제 높이 측정 없음 | 전체 | 측정 수단 부재 | 예상과 실제의 차이 감지 불가 |
| 6 | 넘침이 루프에 없음 | 5 루프 | 넘침 체크 미포함 | 조정 후 넘침 악화 가능 |
---
## 원인 관계
```
근본 원인: Stage 2.5의 넘침 판단 위치가 기존 DOWNGRADE_MAP 위치를 그대로 따름
메커니즘만 변경(DOWNGRADE → Kei), 시점은 재검토 안 함
내용 없이 판단(문제 1) + 주체 잘못됨(문제 2)
실제 넘침이 감지되는 시점(Stage 4 이후)에는 검증 없음(문제 3, 4, 6)
애초에 실제 높이 측정 수단도 없음(문제 5)
```
---
## 해결 방안 조사 결과
### 방안 1: 실제 렌더링 높이 측정 (문제 5 해결)
현재 파이프라인에는 렌더링된 HTML의 실제 px 높이를 측정하는 수단이 없음.
| 도구 | 정확도 | 속도 | CSS Grid | CSS 변수 | 커스텀 폰트 | 설치 상태 |
|------|--------|------|----------|----------|------------|----------|
| **Playwright** | 픽셀 정확 | 20~50ms/요소 | 완전 지원 | 완전 지원 | 완전 지원 | 미설치 |
| Selenium | 픽셀 정확 | 50~150ms/요소 | 완전 지원 | 완전 지원 | 완전 지원 | **설치됨** (4.34.0) |
| WeasyPrint | 제한적 | 200~500ms | 부분 지원 | 제한적 | 지원 | **설치됨** (65.1) |
| 텍스트 추정 | ±15~30% 오차 | <1ms | 불가 | 불가 | 불가 | — |
**권장: Playwright** — 가장 정확하고 빠름. 비동기 지원. headless Chromium 자동 설치.
**차선: Selenium** — 이미 설치됨. 동기식이라 약간 느리지만 충분히 사용 가능.
**측정 방식:**
```python
# Playwright 예시
async with async_playwright() as p:
browser = await p.chromium.launch()
page = await browser.new_page(viewport={"width": 1280, "height": 720})
await page.set_content(html)
# 각 zone의 실제 높이 측정
body_box = await page.locator("[data-zone='body']").bounding_box()
actual_height = body_box["height"] # 실제 렌더링 px
# overflow 감지: scrollHeight > clientHeight
overflow = await page.evaluate("""
el => el.scrollHeight > el.clientHeight
""", await page.query_selector("[data-zone='body']"))
```
---
### 방안 2: Stage 2.5 → Stage 5로 이동 (문제 1, 2, 3, 4, 6 해결)
**현재:** Stage 2.5에서 텍스트 없이 Kei 판단 → 근거 부족
**개선:** Stage 4(렌더링) 이후, Stage 5(재검토) 안에서 넘침 판단
```
현재:
Stage 2 → [2.5 Kei 넘침 판단] → Stage 3 → Stage 4 → Stage 5(Sonnet만)
개선:
Stage 2 → Stage 3 → Stage 4 → Stage 5(Sonnet 감지 + Kei 판단)
```
**Stage 5 역할 확장:**
1. **Sonnet이 감지**: 렌더링된 HTML + zone 예산 정보를 보고 넘침 여부 판단
2. **넘침이면 Kei에게 전달**: 실제 콘텐츠가 있는 상태에서 Kei가 판단
3. **Kei가 결정**: trim(텍스트 축약) 또는 restructure(팝업 분리)
4. **Sonnet이 실행**: CSS 조정 + 재렌더링
**Sonnet + Kei 협업 모델:**
```
Sonnet: "body zone이 520px인데 예산 490px. 30px 초과."
Kei: "꼭지 3의 부연 설명을 축약하면 됨. 핵심은 유지." (trim)
또는
Kei: "12행 비교표는 팝업으로 분리. 슬라이드엔 요약만." (restructure)
Sonnet: CSS 조정 + 재렌더링
```
---
### 방안 3: Stage 2 구조적 검증은 유지하되 역할 한정 (문제 2 해결)
Stage 2의 `_validate_height_budget()`**구조적 검증만** 담당:
- 금지 블록 교체 (BODY_FORBIDDEN_MAP) — 유지
- pill-pair 단독 금지 (I-7) — 유지
- 예상 높이 초과 — **경고만** (Kei 호출 안 함, Stage 5에서 처리)
```python
# Stage 2: 경고만 출력, overflow 정보는 Stage 5에서 활용
if total > budget:
logger.warning(f"[예상 높이 초과] {area}: {total}px > {budget}px (Stage 5에서 검증)")
# Kei 호출 안 함. 실제 렌더링 후 Stage 5에서 정확히 감지.
```
**Sonnet 프롬프트(STEP_B_PROMPT) 개선:**
- 현재: height_cost 매핑을 설명하지만 구체적 예시 없음
- 개선: 계산 예시 추가 + "초과 시 reason 필드에 설명" 명시
---
### 방안 4: 넘침을 Stage 5 재검토 루프에 통합 (문제 6 해결)
**현재 Stage 5 루프:**
```
재검토(Sonnet) → 조정(expand/shrink/rewrite) → 재편집(Kei 편집자) → 재렌더링 → 재검토
```
**개선 Stage 5 루프:**
```
재검토(Sonnet, 넘침 포함)
→ 넘침 있으면: Kei 판단(trim/restructure)
→ 조정 적용(expand/shrink/rewrite/trim/restructure)
→ 재편집(Kei 편집자) → 재렌더링 → 재검토
```
**Stage 5 프롬프트에 추가할 점검 항목:**
```
6. 높이 제약: 각 zone이 예산을 초과하는가?
- 자동 조정(shrink)으로 해결 가능 → shrink
- 불가능 → overflow_detected (Kei 판단 필요)
```
**_apply_adjustments()에 추가할 action:**
- `overflow_detected` → Kei API 호출 → trim/restructure 적용
---
## 해결 방안 매트릭스
| 방안 | 해결하는 문제 | 필요 기술 | 구현 난이도 |
|------|-------------|----------|------------|
| 1. 실제 높이 측정 | 문제 5 | Playwright 또는 Selenium | 중 (의존성 추가) |
| 2. 넘침 판단 Stage 5로 이동 | 문제 1, 2, 3, 4 | 코드 리팩토링 | 중 (Stage 2.5 제거, Stage 5 확장) |
| 3. Stage 2 경고만 | 문제 2 | 코드 수정 | 소 (Kei 호출 제거, 경고만) |
| 4. 넘침을 루프에 통합 | 문제 6 | Stage 5 프롬프트 + 코드 | 중 (새 action + Kei 연동) |
**방안 1은 선택적** — Playwright/Selenium 없이도 Sonnet이 HTML을 읽고 넘침을 추정할 수 있음. 정확도는 떨어지지만 현실적.
**방안 2+3+4는 필수** — 프로세스 구조 자체의 문제이므로 반드시 수정.
---
## 실행 계획: 프로세스 재설계 (방안 2+3+4)
> 충돌/회귀/오류 검토 완료. Phase I 산출물 전부 재사용. 변경 파일 `pipeline.py`만.
> Sonnet 신규 투입 0건. Kei API 호출 위치만 이동. 하드코딩/단발성 없음.
### 변경 전후 프로세스 비교
```
[변경 전]
Stage 1 → Stage 2 → [2.5 Kei 넘침 판단 ⚠️] → Stage 3 → Stage 4 → Stage 5(Sonnet만)
[변경 후]
Stage 1 → Stage 2(경고만) → Stage 3 → Stage 4 → Stage 5(Sonnet 감지 + Kei 판단)
```
### 변경 상세 (5건, pipeline.py만)
#### P-1: Stage 2.5 제거
**위치:** `pipeline.py` 91~136행 (46행)
**작업:** 전체 삭제
**영향:** 없음. overflow 키는 layout_concept에 남아 Stage 5에서 참고.
**Phase I 회귀 검토:**
- `call_kei_overflow_judgment()` — 함수 삭제 안 함. 호출 위치만 Stage 5로 이동.
- `_downgrade_fallback()` — 삭제 안 함. Stage 5에서 비상용.
- `KEI_OVERFLOW_PROMPT` — 삭제 안 함. Stage 5에서 사용.
---
#### P-2: `_review_balance()` 시그니처 + 프롬프트 확장
**위치:** `pipeline.py` 297~363행
**작업:**
1. 시그니처: `(html, layout_concept, content)``(html, layout_concept, content, analysis)` 추가
2. 프롬프트에 zone 예산 정보 + overflow 힌트 추가
3. 점검 항목 6번 추가: "높이 초과 — overflow_detected"
4. 출력 format에 `overflow_detected` action 추가
**변경 내용:**
```python
# 시그니처 변경
async def _review_balance(
html: str,
layout_concept: dict[str, Any],
content: str,
analysis: dict[str, Any], # 추가
) -> dict[str, Any] | None:
# 프롬프트 추가
# 1. zone 예산 정보 (select_preset + LAYOUT_PRESETS에서)
preset_name = select_preset(analysis)
preset = LAYOUT_PRESETS.get(preset_name, {})
zone_budget_lines = [
f"- {name}: ~{z['budget_px']}px (너비 {z['width_pct']}%)"
for name, z in preset.get("zones", {}).items()
]
# 2. Stage 2 예상 overflow 힌트 (있으면)
overflow_hint = layout_concept.get("overflow", [])
# 3. 점검 항목 6번
"6. 높이 초과: 각 zone의 블록+텍스트가 예산을 초과하는가?\n"
" - shrink로 해결 가능 → shrink\n"
" - 불가능 (콘텐츠가 본질적으로 큼) → overflow_detected\n"
# 4. action 추가
"- overflow_detected: 높이 초과로 Kei 판단 필요. 해당 zone과 초과 블록 명시.\n"
```
**충돌:** 기존 5개 점검 + 3개 action 변경 없음. 추가만.
**Sonnet 역할:** 넘침 **감지만**. 판단은 Kei.
---
#### P-3: Stage 5 루프에 Kei 넘침 판단 통합
**위치:** `pipeline.py` 155~180행
**작업:** 루프 내에서 overflow_detected 시 Kei 호출 추가
```python
for review_round in range(MAX_REVIEW_ROUNDS):
review_result = await _review_balance(html, layout_concept, content, analysis)
if not review_result or not review_result.get("needs_adjustment"):
break
# overflow_detected가 있으면 Kei에게 판단 요청
overflow_adjs = [
adj for adj in review_result.get("adjustments", [])
if adj.get("action") == "overflow_detected"
]
if overflow_adjs:
# 실제 콘텐츠가 있는 상태에서 Kei 판단
overflow_context = _build_overflow_context(layout_concept, overflow_adjs)
kei_judgment = await call_kei_overflow_judgment(
overflow_context, content, analysis
)
if kei_judgment is None:
logger.warning("[DOWNGRADE 비상] Kei API 실패")
for page in layout_concept.get("pages", []):
_downgrade_fallback(page.get("blocks", []), overflow_context)
else:
# Kei 판단을 adjustments에 반영 (overflow_detected → kei_trim/restructure)
_convert_kei_judgment(review_result, kei_judgment, analysis)
# 모든 조정 적용 (기존 expand/shrink/rewrite + 신규 kei_trim)
layout_concept = await _apply_adjustments(layout_concept, review_result, content)
html = render_slide(layout_concept)
```
**호출되는 함수:** 모두 Phase I에서 만든 것 재사용
- `call_kei_overflow_judgment()` — kei_client.py (변경 없음, Kei API만 사용)
- `_downgrade_fallback()` — design_director.py (변경 없음)
**신규 헬퍼 함수 2개:**
- `_build_overflow_context()` — overflow_adjs + layout_concept에서 실제 블록 데이터 추출
- `_convert_kei_judgment()` — Kei의 trim/restructure 결정을 review_result.adjustments에 반영
---
#### P-4: `_apply_adjustments()` — kei_trim action 추가
**위치:** `pipeline.py` 366~410행
**작업:** 기존 elif 체인에 kei_trim 분기 추가
```python
# 기존 expand/shrink/rewrite 로직 변경 없음
# 아래 elif만 추가:
elif action == "kei_trim":
max_chars = adj.get("max_chars", 200)
if "char_guide" not in block:
block["char_guide"] = {}
for key in block.get("char_guide", {}):
block["char_guide"][key] = min(block["char_guide"][key], max_chars)
if not block["char_guide"]:
block["char_guide"] = {"text": max_chars}
logger.info(f"조정: {area} → kei_trim max_chars={max_chars}")
elif action == "kei_restructure":
block["detail_target"] = True
if "data" in block:
del block["data"]
block["reason"] = f"재구성: {adj.get('detail', 'Kei 판단 팝업 분리')}"
logger.info(f"조정: {area} → kei_restructure (detail_target)")
```
**충돌:** 없음. 기존 3개 action 변경 0행. 새 elif 추가만.
---
#### P-5: 호출부 수정
**위치:** `pipeline.py` 156행
```python
# 현재:
review_result = await _review_balance(html, layout_concept, content)
# 변경:
review_result = await _review_balance(html, layout_concept, content, analysis)
```
**영향:** 이 함수의 호출부는 pipeline.py 156행 1곳만. 다른 파일에서 호출하지 않음.
---
### 변경 파일 총괄
| 파일 | 변경 | Phase I 코드 영향 |
|------|------|------------------|
| `pipeline.py` | Stage 2.5 제거 + Stage 5 확장 + 헬퍼 2개 + action 2개 | Phase I 함수 재사용, 삭제 0건 |
| `design_director.py` | **변경 없음** | — |
| `kei_client.py` | **변경 없음** | — |
| `content_editor.py` | **변경 없음** | — |
| `sse_utils.py` | **변경 없음** | — |
### 검증 매트릭스
| 항목 | 결과 |
|------|------|
| Phase I 회귀 | **없음** — I-1~I-14 전부 유지, 함수/상수 삭제 0건 |
| Kei API 사용 | **유지**`call_kei_overflow_judgment()` 호출 위치만 Stage 5로 이동 |
| Sonnet이 Kei 역할 대체 | **없음** — Sonnet은 감지만, 판단은 Kei만 |
| 하드코딩 | **없음** — trim max_chars는 Kei가 결정 |
| 단발성 수정 | **없음** — 범용 구조 (어떤 overflow에도 동작) |
| 기존 코드 충돌 | **없음** — overflow 키가 중간 단계에서 무시되는 것 확인 |
| DOWNGRADE 비상용 | **유지** — Stage 5에서 Kei 실패 시 동일하게 작동 |
### 실행 순서
1. P-1: Stage 2.5 제거 (pipeline.py 91~136행 삭제)
2. P-2: `_review_balance()` 시그니처 + 프롬프트 확장
3. P-3: Stage 5 루프에 Kei 연동 + 헬퍼 함수 2개
4. P-4: `_apply_adjustments()` kei_trim/kei_restructure action 추가
5. P-5: 호출부 `analysis` 파라미터 추가
---
## 이력
| 날짜 | 내용 |
|------|------|
| 2026-03-26 | Phase I 실행 완료 후 프로세스 검증 중 발견. 6개 문제 진단. |
| 2026-03-26 | 해결 방안 4개 조사. Playwright 높이 측정 + Stage 5 넘침 통합 방향 도출. |
| 2026-03-26 | **실행 계획 확정.** 충돌/회귀/오류 검토 완료. P-1~P-5 5건, pipeline.py만 변경. Phase I 산출물 전부 재사용. |

View File

@@ -328,31 +328,50 @@ CLAUDE.md 요구사항 전수검토 결과 발견된 미구현/부분구현/위
---
## Phase I: 전수 정합성 복구 + 10가지 런타임 문제 해결 (13개)
## Phase I: 전수 정합성 복구 + 넘침 처리 패러다임 전환 (14개) ✅ 완료
> **실행 상세:** [IMPROVEMENT-PHASE-I.md](IMPROVEMENT-PHASE-I.md)
> 전수 검토에서 발견된 프롬프트 자기모순 + 슬롯 의미 미전달 + 코드 안전망 부족 해결.
> **핵심 변경: 넘침 시 DOWNGRADE_MAP 자동 교체 → Kei 판단 호출로 전환.**
### 패턴 A: 프롬프트 자기모순 (I-1~I-3) — 최우선
- I-1: STEP_B_PROMPT에서 미존재 블록 3개(quote-left-border, card-text-grid, layer-diagram) → 실존 블록으로 교체
- I-2: catalog.yaml not_for에서 미존재 블록 참조 → 실존 블록으로 교체
- I-3: 미등록 블록 교체를 callout-solution 일괄 → purpose 기반 교체
### Phase I-A: 정합성 복구 (7개) ✅
- I-14: `_stream_sse_tokens()` 3개 파일 중복 → `src/sse_utils.py` 공통 유틸 추출
- 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 제거, 트리/개수 정리)
### 패턴 B: 슬롯 의미 미전달 (I-4~I-5)
- I-4: BLOCK_SLOTS 38개 블록에 slot_desc 추가 (각 슬롯의 의미/예시/구조)
- I-5: 편집자 프롬프트에 slot_desc 전달
### Phase I-B: 블록 선택 + 슬롯 의미 (5개) ✅
- I-3: `PURPOSE_FALLBACK` 상수 + purpose 기반 미등록 블록 교체
- I-7: compare-pill-pair 단독 사용 금지 검증 (`COMPARISON_BLOCKS`)
- I-4: 38개 블록 전체에 `slot_desc` 추가 (각 슬롯 의미/형식/예시)
- I-5: 편집자 프롬프트에 slot_desc 전달 로직 (Kei API 경유)
- I-6: 제목 유사도 70% 초과 시 자동 교정 (`SequenceMatcher`)
### 패턴 C: 코드 안전망 (I-6~I-9)
- I-6: 제목 유사도 검증 (70% 이상 → 자동 교정)
- I-7: compare-pill-pair 단독 사용 금지 (비교 테이블 없으면 comparison-2col로 교체)
- I-8: 대형 테이블(5행+) → detail_target 자동 설정
- I-9: DOWNGRADE_MAP 확장 (medium → compact) + 다단계 교체
### Phase I-C: 넘침 처리 패러다임 전환 (2개) ✅
- I-9: `_validate_height_budget()` → overflow 반환 (블록 교체 안 함) + `_downgrade_fallback()` 비상 분리 + `KEI_OVERFLOW_PROMPT` + `call_kei_overflow_judgment()` Kei API 호출 + pipeline Stage 2.5 추가 (trim/restructure 분기)
- I-8: 대형 콘텐츠(테이블/이미지) 정보를 Kei overflow 프롬프트에 포함
### 문서 동기화 (I-10~I-13)
- I-10: INDEX.md 미존재 블록 제거 (46→38)
- I-11: README.md 동기화 (블록 수, Sonnet fallback 제거)
- I-12: BLOCK_SLOTS 주석 수정
- I-13: 데드 코드(_call_anthropic_direct) 제거
---
## 프로세스 재검토: 검증 시점 문제 (Phase I 후속)
> **상세:** [IMPROVEMENT-PROCESS-REVIEW.md](IMPROVEMENT-PROCESS-REVIEW.md)
> Phase I 실행 후 발견. Stage 2.5의 넘침 판단이 텍스트 없는 시점에서 실행되는 구조적 문제.
**문제:** 6건 (내용 없이 판단, 판단 주체 잘못됨, HTML 있는데 넘침 안 봄, Kei 없음, 높이 측정 없음, 루프에 누락)
**원인:** Phase I에서 DOWNGRADE_MAP → Kei 판단으로 메커니즘만 변경, 위치(Stage 2.5)는 기존 코드 관성으로 유지
**해결:** Stage 2.5 제거 → Stage 5에서 Sonnet 감지 + Kei 판단 통합
- P-1: Stage 2.5 제거 (pipeline.py)
- P-2: `_review_balance()` 프롬프트에 zone 예산 + overflow_detected 추가
- P-3: Stage 5 루프에 Kei 넘침 판단 통합
- P-4: `_apply_adjustments()` kei_trim/kei_restructure action 추가
- P-5: 호출부 analysis 파라미터 추가
**Phase I 영향:** 회귀 없음. `call_kei_overflow_judgment()`, `_downgrade_fallback()`, `KEI_OVERFLOW_PROMPT` 전부 재사용. 호출 위치만 이동.
---

205
README.md
View File

@@ -11,44 +11,146 @@
```
텍스트 입력 (+ 이미지 폴더 경로)
[1] Kei 실장 (Kei API → Opus) — 정보 구조 파악 + 꼭지 추출
- 본문 흐름(flow) vs 참조 정보(reference) 분리
- 각 꼭지의 레이어/강조/배치 방향 판단
- 이미지 판단 (개수/소속/핵심·보조/텍스트 포함 여부)
- 표 판단 (행/열 규모, 1페이지 표시 가능 여부)
- fallback: Anthropic Sonnet 직접 호출
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
[1단계] Kei 실장 — 콘텐츠 분석 + 스토리라인 설계
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
사용 AI: Kei API (Opus)
fallback: manual_classify() (최소 구조 생성)
│ 1-A: 정보 구조 파악 + 꼭지 추출
│ - 핵심 메시지(core_message) 도출
│ - 본문 흐름(flow) vs 참조 정보(reference) 분리
│ - 각 꼭지의 레이어/강조/배치 방향 판단
│ - 이미지 판단 (개수/소속/핵심·보조/텍스트 포함 여부)
│ - 표 판단 (행/열 규모, 1페이지 표시 가능 여부)
│ - purpose 부여 (문제제기/근거사례/핵심전달/용어정의/결론강조/구조시각화)
│ 1-B: 각 꼭지 컨셉 구체화
│ - relation_type (비교/포함/계층/인과 등)
│ - expression_hint (표현 방향)
│ - source_data (원본에서 추출할 데이터)
│ 제목 중복 검증 (I-6)
│ - 슬라이드 제목 ↔ 첫 꼭지 제목 유사도 70% 초과 시 자동 교정
│ 이미지 크기 측정 (Pillow)
│ - base_path 있으면 이미지 파일 크기 측정 → analysis에 포함
[2] 디자인 팀장 — 3-Step
Step A-1: 레이아웃 프리셋 자동 선택 (규칙 기반)
- sidebar-right / two-column / hero-detail / single-column
Step A-2: Opus(Kei API) 블록 추천 (FAISS 검색 결과 기반)
- 도메인 지식 + 콘텐츠 성격 기반 블록 추천
- fallback: 추천 없이 Step B로
Step B: 프리셋 안에서 블록 매핑 + 글자 수 가이드 (Sonnet)
- Opus 추천 참고하되 최종 선택은 팀장 판단
- 컨테이너 예산(zone별 높이 px) 기반 블록 선택
- grid는 코드가 프리셋에서 강제 (Sonnet은 blocks만 출력)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
[2단계] 디자인 팀장 — 레이아웃 설계 + 블록 매핑
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Step A-1: 레이아웃 프리셋 자동 선택 (규칙 기반, LLM 불필요)
- sidebar-right / two-column / hero-detail / single-column
- grid는 코드가 프리셋에서 강제 (AI가 변경 불가)
Step A-2: Opus(Kei API) 블록 추천 (FAISS 검색 결과 기반)
사용 AI: Kei API (Opus)
- 도메인 지식 + 콘텐츠 성격 기반 블록 추천
│ - fallback: 추천 없이 Step B로
│ Step B: 블록 매핑 + 글자 수 가이드 (Sonnet)
│ 사용 AI: Anthropic API (Sonnet)
│ - Opus 추천 참고하되 최종 선택은 팀장 판단
│ - 컨테이너 예산(zone별 높이 px) 기반 블록 선택
│ - purpose 기반 블록 선택 가이드 참고
│ - 각 블록에 char_guide(글자 수 가이드) 부여
│ 블록 검증 (코드):
│ - 미등록 블록 → purpose 기반 fallback (PURPOSE_FALLBACK)
│ - 잘못된 zone → 기본 zone 자동 매핑
│ - conclusion 꼭지 → footer zone 강제
│ - compare-pill-pair 단독 사용 → comparison-2col 교체 (I-7)
│ - 금지 블록(section-title-with-bg) → body/sidebar에서 교체
│ 높이 예산 검증 (I-9):
│ - zone별 블록 높이 합산 vs budget_px 비교
│ - 초과 시 → overflow 정보 수집 (블록 자동 교체 안 함)
↓ (overflow 있으면)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
[2.5단계] Kei 실장 — 넘침 판단 (I-9)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
│ 사용 AI: Kei API (Opus)
│ 조건: 2단계에서 overflow 발생 시에만 실행
│ Kei에게 전달: 어떤 zone이 얼마나 초과, 블록/콘텐츠 요약, 대형 테이블/이미지 정보 (I-8)
│ Kei가 판단:
│ Option 1 "trim" → 텍스트 분량 제약 (char_guide 축소) → 3단계에서 반영
│ Option 2 "restructure" → 핵심 재구성 + 상세는 팝업(detail page) 분리
│ → detail_target 설정 후 2단계 재실행
│ Kei API 실패 시: DOWNGRADE_MAP 비상 작동 (기계적 블록 교체)
[3] Kei 텍스트 편집자 (Kei API) — 도메인 전문가로서 텍스트 정리
- 글자 수 가이드 참고, 내용 의미 우선
- 출처 보존, 개조식, 날조 금지
- fallback: Anthropic Sonnet 직접 호출
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
[3단계] Kei 텍스트 편집자 — 도메인 전문가로서 텍스트 정리
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
사용 AI: Kei API (Opus + RAG + 도메인 지식)
│ Sonnet fallback 없음 (Kei API만 사용)
│ - 각 블록의 슬롯에 맞게 텍스트 정리
│ - 슬롯 의미 설명(slot_desc) 참고하여 정확한 데이터 배치 (I-4, I-5)
│ - 글자 수 가이드 참고, 내용 의미 우선
│ - 2.5단계에서 trim 제약이 있으면 반영
│ - 원본 텍스트 최대 보존, 출처 보존, 개조식, 날조 금지
│ - detail_target 꼭지: summary + detail 두 버전 작성
[4] 디자인 실무자 (Sonnet + Jinja2 + CSS Grid) — 디자인 조정 + HTML 조립
- Sonnet이 텍스트 양에 맞게 CSS 변수 override 결정 (폰트/여백/간격)
- Jinja2로 블록 템플릿 렌더링 + CSS 변수 cascade로 자동 적용
- SVG 시각화 블록: 좌표 사전 계산 (svg_calculator.py)
- 이미지 base64 인라인 삽입 (다운로드 HTML에서도 표시)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
[4단계] 디자인 실무자 — 디자인 조정 + HTML 조립
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
사용 AI: Anthropic API (Sonnet) — CSS 변수 override 결정
렌더링: Jinja2 + CSS Grid
│ - Sonnet이 텍스트 양에 맞게 CSS 변수 override 결정
│ (--font-body, --font-subtitle, --spacing-inner, --spacing-block 등)
│ - Jinja2로 블록 템플릿 렌더링
│ - CSS 변수 cascade로 area별 자동 적용
│ - SVG 시각화 블록: 좌표 사전 계산 (svg_calculator.py)
│ - 이미지 base64 인라인 삽입 (다운로드 HTML에서도 표시)
[5] 디자인 팀장 (Sonnet) — 전체 재검토 (최대 2회 루프)
- HTML 전문 기반 균형 점검
- expand/shrink/rewrite 조정 (AI가 target_ratio 결정)
- 조정 후 재렌더링 → 재검토
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
[5단계] 디자인 팀장 — 전체 재검토 (최대 2회 루프)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
사용 AI: Anthropic API (Sonnet) — HTML 기반 균형 점검
│ 점검 항목:
│ - 빈 블록 감지
│ - 채움 불균형 (한 블록은 빽빽, 다른 블록은 비어있음)
│ - 이미지/표 크기 적절성
│ - 전체 정보량 (페이지당 너무 많거나 적은지)
│ 조정 필요 시:
│ - expand: 텍스트 늘림 (target_ratio, 예: 1.3 = 30% 증가)
│ - shrink: 텍스트 줄임 (target_ratio, 예: 0.7 = 30% 감소)
│ - rewrite: 텍스트 재작성 (방향 명시)
│ → 3단계(Kei 편집자) 재호출 → 4단계 재렌더링 → 재검토
│ 조정 불필요 또는 2회 완료 시 확정
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
미리보기 + HTML 다운로드
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
```
## 블록 라이브러리 (46개 + _legacy 13개)
### 각 단계별 AI 담당
| 단계 | 담당 | AI | session_id |
|------|------|-----|-----------|
| 1-A | Kei 실장 | Kei API (Opus) | `design-agent` |
| 1-B | Kei 실장 | Kei API (Opus) | `design-agent-refine` |
| 2 A-2 | Kei 실장 | Kei API (Opus) | `design-agent-opus` |
| 2 B | 디자인 팀장 | Anthropic (Sonnet) | — |
| 2.5 | Kei 실장 | Kei API (Opus) | `design-agent-overflow` |
| 3 | Kei 편집자 | Kei API (Opus) | `design-agent-editor` |
| 4 | 디자인 실무자 | Anthropic (Sonnet) | — |
| 5 | 디자인 팀장 | Anthropic (Sonnet) | — |
## 블록 라이브러리 (38개)
```
templates/blocks/
@@ -59,8 +161,7 @@ templates/blocks/
│ ├── topic-left-right.html 좌:제목 + 우:설명
│ ├── topic-center.html 중앙 정렬 제목
│ └── topic-numbered.html 번호 + 제목 + 설명
├── cards/ (10개) 카드 계열
│ ├── card-text-grid.html 텍스트 카드 2~4열
├── cards/ (9개) 카드 계열
│ ├── card-image-3col.html 이미지 카드 3열
│ ├── card-dark-overlay.html 다크 오버레이 카드
│ ├── card-tag-image.html 태그 + 이미지 카드
@@ -74,22 +175,16 @@ templates/blocks/
│ ├── compare-3col-badge.html A|VS배지|B 3단 비교
│ ├── compare-2col-split.html 좌우 분할 비교
│ └── table-simple-striped.html 줄무늬 일반 테이블
├── visuals/ (10개) 다이어그램, 관계도 (SVG)
├── visuals/ (6개) 다이어그램, 관계도 (SVG)
│ ├── venn-diagram.html 벤 다이어그램 (N개 동적)
│ ├── circle-gradient.html 그라데이션 원 + 텍스트
│ ├── compare-pill-pair.html 둥근 박스 2개 + VS
│ ├── process-horizontal.html 가로 단계 흐름
│ ├── flow-arrow-horizontal.html 가로 화살표 흐름
── keyword-circle-row.html 키워드 원형 나열
│ ├── layer-diagram.html 레이어 다이어그램
│ ├── timeline-vertical.html 세로 타임라인
│ ├── timeline-horizontal.html 가로 타임라인
│ └── pyramid-hierarchy.html 피라미드 계층
├── emphasis/ (13개) 강조, 인용, 결론
│ ├── quote-left-border.html 좌측 라인 인용
── keyword-circle-row.html 키워드 원형 나열
├── emphasis/ (10개) 강조, 인용, 결론
│ ├── quote-big-mark.html 큰 따옴표 인용
│ ├── quote-question.html 질문형 강조
│ ├── conclusion-accent-bar.html 좌측 라인 결론
│ ├── comparison-2col.html 2단 비교
│ ├── banner-gradient.html 그라데이션 배너
│ ├── dark-bullet-list.html 다크 배경 불릿 리스트
@@ -97,24 +192,22 @@ templates/blocks/
│ ├── callout-solution.html 솔루션 콜아웃
│ ├── callout-warning.html 경고 콜아웃
│ ├── tab-label-row.html 탭 라벨 행
── divider-text.html 텍스트 구분선
│ └── details-block.html 자세히보기 (접기/펼치기)
├── media/ (5개) 이미지/미디어
├── image-row-2col.html 이미지 2장 나란히
├── image-grid-2x2.html 이미지 2x2 그리드
├── image-side-text.html 이미지 + 텍스트
── image-full-caption.html 전체 너비 이미지 + 캡션
│ └── image-before-after.html Before/After 비교
└── _legacy/ (13개) 이전 버전 (fallback)
── divider-text.html 텍스트 구분선
└── media/ (5개) 이미지/미디어
├── image-row-2col.html 이미지 2장 나란히
├── image-grid-2x2.html 이미지 2x2 그리드
├── image-side-text.html 이미지 + 텍스트
├── image-full-caption.html 전체 너비 이미지 + 캡션
── image-before-after.html Before/After 비교
```
## FAISS 블록 검색
46개 블록 전체를 프롬프트에 넣는 대신, FAISS로 꼭지별 관련 블록만 검색하여 전달합니다.
38개 블록 전체를 프롬프트에 넣는 대신, FAISS로 꼭지별 관련 블록만 검색하여 전달합니다.
```
꼭지 "A vs B 비교" → FAISS 검색 → comparison-2col, compare-pill-pair, compare-2col-split
꼭지 "연도별 로드맵" → FAISS 검색 → timeline-vertical, timeline-horizontal, card-step-vertical
꼭지 "연도별 로드맵" → FAISS 검색 → process-horizontal, flow-arrow-horizontal, card-step-vertical
```
- 임베딩 모델: BAAI/bge-m3 (1024차원, 한국어 최적화)
@@ -143,7 +236,7 @@ grid는 코드(Step A)가 결정. Sonnet은 blocks만 출력. grid 변경 불가
| AI (3단계 편집자) | Kei API → fallback: Sonnet |
| AI (4단계 실무자) | Anthropic API (Sonnet) — CSS 조정 |
| AI (5단계 재검토) | Anthropic API (Sonnet) — 균형 점검 |
| 블록 검색 | FAISS + bge-m3 (46개 블록 인덱스) |
| 블록 검색 | FAISS + bge-m3 (38개 블록 인덱스) |
| 템플릿 | Jinja2 (카테고리별 블록 조합) |
| 렌더링 | CSS Grid + 디자인 토큰 (16:9, 1280×720) |
| SVG 시각화 | svg_calculator.py (cos/sin 좌표 계산, N개 동적) |
@@ -220,8 +313,8 @@ design_agent/
├── templates/
│ ├── slide-base.html 슬라이드 베이스 (다중 페이지 + 인쇄 JS)
│ ├── catalog.yaml 블록 카탈로그 (46개, height_cost 포함)
│ └── blocks/ 블록 라이브러리 (6 카테고리, 46개)
│ ├── catalog.yaml 블록 카탈로그 (38개, height_cost 포함)
│ └── blocks/ 블록 라이브러리 (6 카테고리, 38개)
│ ├── INDEX.md 전체 인덱스
│ ├── headers/ (5) 타이틀, 꼭지 헤더
│ ├── cards/ (10) 카드 계열
@@ -229,7 +322,7 @@ design_agent/
│ ├── visuals/ (10) 다이어그램, 관계도 (SVG)
│ ├── emphasis/ (13) 강조, 인용, 결론, 자세히보기
│ ├── media/ (5) 이미지/미디어
│ └── _legacy/ (13) 이전 버전 (fallback)
│ └── media/ (5) 이미지/미디어
├── static/
│ ├── index.html 프론트엔드 (이미지 경로 입력 팝업 포함)

View File

@@ -19,6 +19,7 @@ import httpx
from src.config import settings
from src.design_director import BLOCK_SLOTS
from src.sse_utils import stream_sse_tokens
logger = logging.getLogger(__name__)
@@ -92,6 +93,12 @@ async def fill_content(
f" 선택 슬롯: {slots.get('optional', [])}"
)
# I-5: 슬롯 의미 설명 전달 (slot_desc가 있으면)
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)
if char_guide:
guide_lines = [f" {k}: ~{v}" for k, v in char_guide.items()]
req_text += "\n 글자 수 가이드 (참고, 의미 우선):\n" + "\n".join(guide_lines)
@@ -188,7 +195,7 @@ async def _call_kei_editor(prompt: str) -> str | None:
logger.warning(f"Kei API (editor) HTTP {response.status_code}")
return None
full_text = await _stream_sse_tokens(response)
full_text = await stream_sse_tokens(response)
if full_text:
return full_text
@@ -201,65 +208,6 @@ async def _call_kei_editor(prompt: str) -> str | None:
return None
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)
def _extract_sse_text(raw: str) -> str:
"""SSE 응답에서 토큰 텍스트를 수집한다."""
import re as _re
tokens = []
events = _re.split(r'\r?\n\r?\n', raw)
for event in events:
if not event.strip():
continue
event_type = ""
event_data = ""
for line in event.split('\n'):
line = line.strip('\r')
if line.startswith('event:'):
event_type = line[6:].strip()
elif line.startswith('data:'):
event_data = line[5:].strip()
if not event_data:
continue
if event_type == 'token':
try:
token = json.loads(event_data)
if isinstance(token, str):
tokens.append(token)
except json.JSONDecodeError:
tokens.append(event_data)
elif event_type == 'done':
break
return "".join(tokens)
def _apply_defaults(blocks: list[dict[str, Any]]) -> None:
"""실패 시 기본 데이터 적용."""

View File

@@ -16,6 +16,7 @@ import httpx
import yaml
from src.config import settings
from src.sse_utils import stream_sse_tokens
logger = logging.getLogger(__name__)
@@ -24,49 +25,285 @@ logger = logging.getLogger(__name__)
# ──────────────────────────────────────
BLOCK_SLOTS = {
# headers/ (5개)
"section-title-with-bg": {"required": ["title_ko"], "optional": ["title_en", "breadcrumb", "bg_image"]},
"section-header-bar": {"required": ["title"], "optional": ["subtitle"]},
"topic-left-right": {"required": ["title", "description"], "optional": []},
"topic-center": {"required": ["title"], "optional": ["subtitle", "description"]},
"topic-numbered": {"required": ["number", "title"], "optional": ["description", "color"]},
# cards/ (10개)
"card-image-3col": {"required": ["cards"], "optional": []},
"card-dark-overlay": {"required": ["cards"], "optional": []},
"card-tag-image": {"required": ["cards"], "optional": []},
"card-icon-desc": {"required": ["cards"], "optional": []},
"card-compare-3col": {"required": ["cards"], "optional": []},
"card-step-vertical": {"required": ["steps"], "optional": []},
"card-image-round": {"required": ["cards"], "optional": []},
"card-stat-number": {"required": ["stats"], "optional": []},
"card-numbered": {"required": ["items"], "optional": []},
"section-title-with-bg": {
"required": ["title_ko"], "optional": ["title_en", "breadcrumb", "bg_image"],
"slot_desc": {
"title_ko": "한글 메인 타이틀",
"title_en": "영문 서브 타이틀 (없으면 생략)",
"breadcrumb": "상위 카테고리 경로 (예: 디지털전환 > BIM)",
"bg_image": "배경 이미지 경로",
},
},
"section-header-bar": {
"required": ["title"], "optional": ["subtitle"],
"slot_desc": {
"title": "섹션 제목 (짧고 굵게)",
"subtitle": "보조 설명 (한 줄)",
},
},
"topic-left-right": {
"required": ["title", "description"], "optional": [],
"slot_desc": {
"title": "꼭지 제목 (좌측, 굵게)",
"description": "꼭지 설명 (우측, 2~3줄)",
},
},
"topic-center": {
"required": ["title"], "optional": ["subtitle", "description"],
"slot_desc": {
"title": "중앙 정렬 대제목",
"subtitle": "부제목 (작은 글씨)",
"description": "추가 설명 (1~2줄)",
},
},
"topic-numbered": {
"required": ["number", "title"], "optional": ["description", "color"],
"slot_desc": {
"number": "순번 (1, 2, 3 등)",
"title": "단계/항목 제목",
"description": "설명 텍스트",
"color": "원형 번호 색상 (CSS 색상값)",
},
},
# cards/ (9개)
"card-image-3col": {
"required": ["cards"], "optional": [],
"slot_desc": {
"cards": "카드 배열. 각 카드: {image: '이미지 경로', title: '제목', title_en: '영문', bullets: ['항목1', '항목2']}. 3장.",
},
},
"card-dark-overlay": {
"required": ["cards"], "optional": [],
"slot_desc": {
"cards": "카드 배열. 각 카드: {image: '배경 이미지', title: '키워드', description: '짧은 설명'}. 3~5장.",
},
},
"card-tag-image": {
"required": ["cards"], "optional": [],
"slot_desc": {
"cards": "카드 배열. 각 카드: {tag: '카테고리 라벨', tag_color: '색상', image: '이미지', title: '제목', description: '설명'}. 3장.",
},
},
"card-icon-desc": {
"required": ["cards"], "optional": [],
"slot_desc": {
"cards": "카드 배열. 각 카드: {icon: '이모지', title: '제목', description: '설명 (2~3줄)'}. 2~4장.",
},
},
"card-compare-3col": {
"required": ["cards"], "optional": [],
"slot_desc": {
"cards": "비교 카드 배열. 각 카드: {header: '카테고리명', header_color: '색상', bullets: ['항목1', '항목2']}. 3장.",
},
},
"card-step-vertical": {
"required": ["steps"], "optional": [],
"slot_desc": {
"steps": "단계 배열. 각 단계: {number: '01', title: '단계명', description: '설명', image: '이미지(선택)'}. 3~5단계.",
},
},
"card-image-round": {
"required": ["cards"], "optional": [],
"slot_desc": {
"cards": "카드 배열. 각 카드: {image: '원형 이미지', title: '제목', description: '설명'}. 2~3장.",
},
},
"card-stat-number": {
"required": ["stats"], "optional": [],
"slot_desc": {
"stats": "통계 배열. 각 항목: {number: '85', unit: '%', label: '비용 절감율'}. 2~4개. 숫자는 출처 있는 것만!",
},
},
"card-numbered": {
"required": ["items"], "optional": [],
"slot_desc": {
"items": "항목 배열. 각 항목: {title: '항목 제목', description: '설명'}. 3~5개.",
},
},
# tables/ (3개)
"compare-3col-badge": {"required": ["headers", "rows"], "optional": []},
"compare-2col-split": {"required": ["left_title", "right_title", "rows"], "optional": []},
"table-simple-striped": {"required": ["headers", "rows"], "optional": []},
# visuals/ (10개)
"venn-diagram": {"required": ["center_label", "items"], "optional": ["center_sub", "description"]},
"circle-gradient": {"required": ["label"], "optional": ["sub_label"]},
"compare-pill-pair": {"required": ["left_label", "right_label"], "optional": ["left_sub", "right_sub"]},
"process-horizontal": {"required": ["steps"], "optional": []},
"flow-arrow-horizontal": {"required": ["steps"], "optional": []},
"keyword-circle-row": {"required": ["keywords"], "optional": []},
# emphasis/ (12개)
"quote-big-mark": {"required": ["quote_text"], "optional": ["source"]},
"quote-question": {"required": ["question"], "optional": ["description"]},
"comparison-2col": {"required": ["left_title", "left_content", "right_title", "right_content"], "optional": ["left_subtitle", "right_subtitle"]},
"banner-gradient": {"required": ["text"], "optional": ["sub_text"]},
"dark-bullet-list": {"required": ["bullets"], "optional": ["title"]},
"highlight-strip": {"required": ["segments"], "optional": []},
"callout-solution": {"required": ["title", "description"], "optional": ["icon", "source"]},
"callout-warning": {"required": ["title", "description"], "optional": ["icon"]},
"tab-label-row": {"required": ["tabs"], "optional": []},
"divider-text": {"required": ["text"], "optional": []},
"compare-3col-badge": {
"required": ["headers", "rows"], "optional": [],
"slot_desc": {
"headers": "3개 열 헤더 배열: ['항목', 'A 대상', 'B 대상']",
"rows": "비교 행 배열. 각 행: {criteria: '비교 기준', left: 'A 내용', right: 'B 내용'}. 최소 3행.",
},
},
"compare-2col-split": {
"required": ["left_title", "right_title", "rows"], "optional": [],
"slot_desc": {
"left_title": "왼쪽 열 헤더",
"right_title": "오른쪽 열 헤더",
"rows": "비교 행 배열. 각 행: {criteria: '비교 기준', left: '왼쪽 내용', right: '오른쪽 내용'}. 최소 3행.",
},
},
"table-simple-striped": {
"required": ["headers", "rows"], "optional": [],
"slot_desc": {
"headers": "열 헤더 배열: ['열1', '열2', '열3']",
"rows": "데이터 행 배열. 각 행: ['셀1', '셀2', '셀3']. 행 수 자유.",
},
},
# visuals/ (6개)
"venn-diagram": {
"required": ["center_label", "items"], "optional": ["center_sub", "description"],
"slot_desc": {
"center_label": "중앙 교집합 라벨 (핵심 키워드)",
"items": "원 배열. 각 원: {label: '영역명', sub: '설명'}. 2~5개.",
"center_sub": "중앙 부가 설명",
"description": "다이어그램 하단 설명",
},
},
"circle-gradient": {
"required": ["label"], "optional": ["sub_label"],
"slot_desc": {
"label": "원 중앙 메인 텍스트 (키워드, 1~2단어)",
"sub_label": "원 아래 보조 텍스트",
},
},
"compare-pill-pair": {
"required": ["left_label", "right_label"], "optional": ["left_sub", "right_sub"],
"slot_desc": {
"left_label": "왼쪽 개념명 (1~2단어)",
"right_label": "오른쪽 개념명 (1~2단어)",
"left_sub": "왼쪽 보조 설명",
"right_sub": "오른쪽 보조 설명",
},
},
"process-horizontal": {
"required": ["steps"], "optional": [],
"slot_desc": {
"steps": "단계 배열. 각 단계: {number: '01', title: '단계명', description: '설명'}. 3~5단계.",
},
},
"flow-arrow-horizontal": {
"required": ["steps"], "optional": [],
"slot_desc": {
"steps": "흐름 배열. 각 항목: {label: '단계명'}. 3~5개. 화살표로 연결됨.",
},
},
"keyword-circle-row": {
"required": ["keywords"], "optional": [],
"slot_desc": {
"keywords": "키워드 배열. 각 항목: {letter: '약어 (G)', label: '풀네임', description: '설명'}. 3~5개.",
},
},
# emphasis/ (10개)
"quote-big-mark": {
"required": ["quote_text"], "optional": ["source"],
"slot_desc": {
"quote_text": "인용할 본문 텍스트 (핵심 발언, 1~3문장)",
"source": "출처 (예: 국토교통부, 2024). 꼭지 제목이 아님!",
},
},
"quote-question": {
"required": ["question"], "optional": ["description"],
"slot_desc": {
"question": "독자에게 던지는 질문 (1문장, 물음표로 끝)",
"description": "질문에 대한 부연 (1~2줄)",
},
},
"comparison-2col": {
"required": ["left_title", "left_content", "right_title", "right_content"],
"optional": ["left_subtitle", "right_subtitle"],
"slot_desc": {
"left_title": "왼쪽 개념 제목 (파란색)",
"left_content": "왼쪽 본문 (불릿 또는 문장)",
"right_title": "오른쪽 개념 제목 (빨간색)",
"right_content": "오른쪽 본문 (불릿 또는 문장)",
"left_subtitle": "왼쪽 보조 제목",
"right_subtitle": "오른쪽 보조 제목",
},
},
"banner-gradient": {
"required": ["text"], "optional": ["sub_text"],
"slot_desc": {
"text": "핵심 결론 한 줄 (굵은 대형 텍스트. 가장 중요한 메시지)",
"sub_text": "부연 설명 (작은 보조 텍스트. text보다 덜 중요)",
},
},
"dark-bullet-list": {
"required": ["bullets"], "optional": ["title"],
"slot_desc": {
"title": "리스트 상단 제목 (파란색, 선택)",
"bullets": "불릿 항목 배열: ['핵심 포인트 1', '핵심 포인트 2']. 3~5개.",
},
},
"highlight-strip": {
"required": ["segments"], "optional": [],
"slot_desc": {
"segments": "색상 구간 배열. 각 구간: {label: '카테고리명', color: '색상'}. 3~5개.",
},
},
"callout-solution": {
"required": ["title", "description"], "optional": ["icon", "source"],
"slot_desc": {
"title": "솔루션/방향성 제목",
"description": "상세 설명 (2~3줄)",
"icon": "아이콘 이모지 (예: 💡)",
"source": "출처 (있으면)",
},
},
"callout-warning": {
"required": ["title", "description"], "optional": ["icon"],
"slot_desc": {
"title": "문제점/경고 제목",
"description": "상세 설명 (2~3줄)",
"icon": "아이콘 이모지 (예: ⚠️)",
},
},
"tab-label-row": {
"required": ["tabs"], "optional": [],
"slot_desc": {
"tabs": "탭 배열. 각 탭: {label: '탭 이름', active: true/false}. 3~5개. 하나만 active.",
},
},
"divider-text": {
"required": ["text"], "optional": [],
"slot_desc": {
"text": "구분선 중앙 텍스트 (짧은 전환 문구, 1~5단어)",
},
},
# media/ (5개)
"image-row-2col": {"required": ["images"], "optional": []},
"image-grid-2x2": {"required": ["images"], "optional": []},
"image-side-text": {"required": ["image_src"], "optional": ["image_alt", "title", "description", "bullets"]},
"image-full-caption": {"required": ["src"], "optional": ["alt", "caption"]},
"image-before-after": {"required": ["before_src", "after_src"], "optional": ["before_label", "after_label", "caption"]},
"image-row-2col": {
"required": ["images"], "optional": [],
"slot_desc": {
"images": "이미지 배열. 각 항목: {src: '이미지 경로', alt: '설명', caption: '캡션'}. 2장.",
},
},
"image-grid-2x2": {
"required": ["images"], "optional": [],
"slot_desc": {
"images": "이미지 배열. 각 항목: {src: '이미지 경로', alt: '설명'}. 4장 (2x2).",
},
},
"image-side-text": {
"required": ["image_src"], "optional": ["image_alt", "title", "description", "bullets"],
"slot_desc": {
"image_src": "좌측 이미지 경로",
"image_alt": "이미지 대체 텍스트",
"title": "우측 제목",
"description": "우측 설명 텍스트",
"bullets": "우측 불릿 항목 배열: ['항목1', '항목2']",
},
},
"image-full-caption": {
"required": ["src"], "optional": ["alt", "caption"],
"slot_desc": {
"src": "전체 너비 이미지 경로",
"alt": "이미지 대체 텍스트",
"caption": "이미지 하단 캡션",
},
},
"image-before-after": {
"required": ["before_src", "after_src"], "optional": ["before_label", "after_label", "caption"],
"slot_desc": {
"before_src": "Before 이미지 경로",
"after_src": "After 이미지 경로",
"before_label": "Before 라벨 (기본: Before)",
"after_label": "After 라벨 (기본: After)",
"caption": "비교 설명 캡션",
},
},
}
# ──────────────────────────────────────
@@ -264,11 +501,11 @@ header/footer는 고정이므로 건드리지 않는다.
## purpose 기반 블록 선택 가이드 (참고, 강제 아님)
각 꼭지의 purpose에 맞는 블록 계열을 선택하라:
- 문제제기 → callout-warning, quote-big-mark, quote-question
- 근거사례 → quote-left-border (출처 포함), card-text-grid (항목 나열)
- 근거사례 → quote-big-mark (출처 포함), card-icon-desc (항목 나열)
- 핵심전달 → comparison-2col, compare-pill-pair, compare-2col-split
- 용어정의 → card-text-grid (정의+출처), card-numbered (순서 있으면)
- 용어정의 → card-icon-desc (정의+출처), card-numbered (순서 있으면)
- 결론강조 → banner-gradient (footer)
- 구조시각화 → venn-diagram, layer-diagram (단독 배치)
- 구조시각화 → venn-diagram (단독 배치)
## 허용된 블록 id 목록 (이 목록에 없는 블록은 절대 선택하지 마라)
{allowed_ids}
@@ -364,7 +601,7 @@ async def _opus_block_recommendation(
logger.warning(f"[Step A-2] Kei API HTTP {response.status_code}")
return None
full_text = await _stream_sse_tokens(response)
full_text = await stream_sse_tokens(response)
if not full_text:
logger.warning("[Step A-2] Kei API 응답 텍스트 없음")
@@ -386,35 +623,6 @@ async def _opus_block_recommendation(
return None
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)
async def create_layout_concept(
content: str,
@@ -567,11 +775,13 @@ async def create_layout_concept(
for block in blocks:
block_type = block.get("type", "")
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"'callout-solution'으로 교체"
f"'{fallback}'으로 교체 (purpose={purpose})"
)
block["type"] = "callout-solution"
block["type"] = fallback
# area명 검증: 프리셋 zone에 없으면 기본 zone으로 매핑
valid_zones = {z for z in preset["zones"] if z != "header"}
@@ -597,13 +807,14 @@ async def create_layout_concept(
)
block["area"] = "footer"
# 5번: zone별 height_cost 합산 검증 — 초과 시 큰 블록 교체
_validate_height_budget(blocks, preset)
# 5번: zone별 height_cost 합산 검증 (I-9: overflow 수집, 블록 교체 안 함)
overflows = _validate_height_budget(blocks, preset)
logger.info(
f"[Step B] 블록 매핑 완료: {preset_name}, {len(blocks)}개 블록"
+ (f", overflow {len(overflows)}" if overflows else "")
)
return {
result = {
"title": analysis.get("title", "슬라이드"),
"pages": [{
"grid_areas": preset["grid_areas"],
@@ -612,6 +823,9 @@ async def create_layout_concept(
"blocks": blocks,
}],
}
if overflows:
result["overflow"] = overflows
return result
else:
logger.warning("블록 매핑 JSON 파싱 실패. fallback.")
@@ -670,6 +884,16 @@ HEIGHT_COST_PX = {
"xlarge": 400,
}
# 미등록 블록 거부 시 purpose 기반 대체 (I-3)
PURPOSE_FALLBACK = {
"문제제기": "callout-warning",
"근거사례": "quote-big-mark",
"핵심전달": "comparison-2col",
"용어정의": "card-icon-desc",
"결론강조": "banner-gradient",
"구조시각화": "card-icon-desc",
}
# body/sidebar/footer zone에서 사용 금지인 블록 → 교체
BODY_FORBIDDEN_MAP = {
"section-title-with-bg": "topic-center", # 500px 블록 → compact 헤더로
@@ -708,11 +932,16 @@ def _load_catalog_map_for_height() -> dict[str, str]:
return {}
def _validate_height_budget(blocks: list[dict], preset: dict) -> None:
"""zone별 height_cost 합산을 검증하고, 초과 시 큰 블록을 교체한다.
def _validate_height_budget(blocks: list[dict], preset: dict) -> list[dict]:
"""zone별 height_cost 합산을 검증한다. (I-9 개정)
또한 body/sidebar/footer zone에서 금지 블록 교체한다.
코드 레벨 검증 — Sonnet이 높이 예산을 안 지켜도 강제 교정.
금지 블록 교체, pill-pair 단독 검증은 수행하되,
높이 초과 시 블록을 자동 교체하지 않는다.
대신 overflow 정보를 수집하여 반환 → pipeline에서 Kei에게 판단 요청.
DOWNGRADE_MAP은 Kei API 실패 시 비상용으로만 사용.
Returns:
overflow 정보 리스트. 초과 없으면 빈 리스트.
"""
zones = preset.get("zones", {})
gap_px = 20 # --spacing-block
@@ -736,11 +965,24 @@ def _validate_height_budget(blocks: list[dict], preset: dict) -> None:
)
block["type"] = replacement
# compare-pill-pair 단독 사용 금지 (I-7)
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"
)
# 높이 예산 검증 — 초과 시 overflow 정보 수집 (블록 교체 안 함)
overflows: list[dict] = []
for area, area_blocks in zone_blocks.items():
zone_info = zones.get(area, {})
budget = zone_info.get("budget_px", 490)
# 총 높이 계산
total = sum(_get_block_height(b.get("type", "")) for b in area_blocks)
total += gap_px * max(0, len(area_blocks) - 1)
@@ -752,8 +994,39 @@ def _validate_height_budget(blocks: list[dict], preset: dict) -> None:
f"블록: {[b.get('type') for b in area_blocks]}"
)
# 가장 큰 블록부터 교체 시도
area_blocks.sort(key=lambda b: _get_block_height(b.get("type", "")), reverse=True)
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
def _downgrade_fallback(blocks: list[dict], overflows: list[dict]) -> None:
"""Kei API 실패 시 비상용 기계적 블록 교체.
기존 DOWNGRADE_MAP 로직. 정상 경로가 아닌 비상 경로.
"""
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", "")
@@ -767,8 +1040,8 @@ def _validate_height_budget(blocks: list[dict], preset: dict) -> None:
block["type"] = replacement
total = total - old_height + new_height
logger.info(
f"[높이 교체] {block_type}({old_height}px) → "
logger.warning(
f"[DOWNGRADE 비상] {block_type}({old_height}px) → "
f"{replacement}({new_height}px). 잔여: {total}px/{budget}px"
)

View File

@@ -10,10 +10,10 @@ import logging
import re
from typing import Any
import anthropic
import httpx
from src.config import settings
from src.sse_utils import stream_sse_tokens
logger = logging.getLogger(__name__)
@@ -167,7 +167,7 @@ async def refine_concepts(
logger.warning(f"[1단계-B] Kei API HTTP {response.status_code}")
return analysis
full_text = await _stream_sse_tokens(response)
full_text = await stream_sse_tokens(response)
if not full_text:
logger.warning("[1단계-B] 응답 텍스트 없음. 1단계-A 결과 유지.")
@@ -214,7 +214,7 @@ async def _call_kei_api(content: str) -> dict[str, Any] | None:
logger.warning(f"Kei API HTTP {response.status_code}")
return None
full_text = await _stream_sse_tokens(response)
full_text = await stream_sse_tokens(response)
if not full_text:
logger.warning("Kei API 응답에서 텍스트 추출 실패")
@@ -232,128 +232,105 @@ async def _call_kei_api(content: str) -> dict[str, Any] | None:
return None
async def _stream_sse_tokens(response: httpx.Response) -> str:
"""SSE 스트리밍 응답에서 토큰을 실시간 수집한다.
persona_agent의 SSE 이벤트:
- token: 텍스트 토큰 수집
- done: 완료, 중단
- error: 에러, 즉시 중단
- planning/planning_done/research_progress/warning: 스킵
# ──────────────────────────────────────
# I-9: Kei 넘침 판단 호출
# ──────────────────────────────────────
KEI_OVERFLOW_PROMPT = """당신은 슬라이드 콘텐츠 전문가이다.
디자인 팀장이 배치한 블록들이 컨테이너(zone)의 높이 예산을 초과했다.
콘텐츠의 중요도와 전달 메시지를 기준으로 어떻게 처리할지 판단하라.
## 판단 기준
- 텍스트 분량만 줄이면 현재 블록 구조 안에서 해결되는가? → "trim"
- 콘텐츠 자체가 컨테이너에 담기엔 본질적으로 많은가? → "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(
overflows: list[dict],
content: str,
analysis: dict[str, Any],
) -> dict[str, Any] | None:
"""Kei API에 넘침 상황을 전달하고 판단을 받는다.
반드시 Kei API 경유. Anthropic 직접 호출 절대 금지.
fallback: None 반환 → pipeline에서 DOWNGRADE 비상 작동.
"""
tokens: list[str] = []
event_type = ""
kei_url = getattr(settings, "kei_api_url", "http://localhost:8000")
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
overflow_desc = json.dumps(overflows, ensure_ascii=False, indent=2)
topics_desc = json.dumps(
[
{
"id": t.get("id"),
"title": t.get("title", ""),
"purpose": t.get("purpose", ""),
"summary": t.get("summary", "")[:100],
}
for t in analysis.get("topics", [])
],
ensure_ascii=False,
)
return "".join(tokens)
# I-8: 대형 콘텐츠 정보 포함
extra_info = ""
tables = analysis.get("tables", [])
if tables:
extra_info += f"\n\n## 테이블 정보\n{json.dumps(tables, ensure_ascii=False)}"
images = analysis.get("images", [])
if images:
extra_info += f"\n\n## 이미지 정보\n{json.dumps(images, ensure_ascii=False)}"
def _extract_sse_text(raw: str) -> str:
"""SSE 응답에서 토큰 텍스트를 수집한다. CRLF/LF 모두 처리."""
tokens = []
# CRLF 또는 LF로 이벤트 분리
events = re.split(r'\r?\n\r?\n', raw)
for event in events:
if not event.strip():
continue
event_type = ""
event_data = ""
for line in event.split('\n'):
line = line.strip('\r')
if line.startswith('event:'):
event_type = line[6:].strip()
elif line.startswith('data:'):
event_data = line[5:].strip()
if not event_data:
continue
if event_type == 'token':
try:
token = json.loads(event_data)
if isinstance(token, str):
tokens.append(token)
except json.JSONDecodeError:
tokens.append(event_data)
elif event_type == 'done':
break
return "".join(tokens)
async def _call_anthropic_direct(content: str) -> dict[str, Any] | None:
"""Anthropic API 직접 호출 (Kei API fallback)."""
if not settings.anthropic_api_key:
return None
system_prompt = (
"당신은 콘텐츠를 분석하여 슬라이드 구조를 설계하는 실장이다.\n\n"
"## 핵심 원칙\n"
"- 원본의 논리 흐름과 정보를 빠뜨리지 마라\n"
"- 원본에 있는 내용을 임의로 제거하거나 다른 의미로 바꾸지 마라\n"
"- 슬라이드에 맞게 정리하되, 원본이 말하려는 흐름은 유지\n\n"
"## 꼭지 추출 규칙\n"
"- 본문에서 2~5개의 핵심 꼭지를 추출한다\n"
"- 참조 정보는 role: 'reference', 본문 흐름은 role: 'flow'로 표시\n"
"- 1페이지 적정 꼭지 수: 5개\n"
"- 초과 시 2페이지 분리\n"
"- 이미지가 있으면 images[]에, 표가 있으면 tables[]에 판단 기록\n\n"
"## 출력 형식 (JSON만. 설명 없이.)\n"
'{"title": "제목", "total_pages": 1, '
'"info_structure": "정보 구조 설명", '
'"topics": ['
'{"id": 1, "title": "꼭지 제목", "summary": "요약", '
'"layer": "intro|core|supporting|conclusion", '
'"role": "flow|reference", '
'"emphasis": true, "direction": "vertical|horizontal|flexible", '
'"content_type": "text|image|table|mixed", '
'"detail_target": false, "page": 1}], '
'"images": [], "tables": []}'
prompt = (
KEI_OVERFLOW_PROMPT + "\n\n"
f"## 넘침 현황\n{overflow_desc}\n\n"
f"## 꼭지 목록\n{topics_desc}"
f"{extra_info}\n\n"
f"## 원본 콘텐츠 요약\n{content[:2000]}"
)
try:
client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
response = await client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=2048,
system=system_prompt,
messages=[{"role": "user", "content": f"다음 콘텐츠의 꼭지를 추출해줘:\n\n{content}"}],
)
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)
result_text = response.content[0].text
result = _parse_json(result_text)
if result and "topics" in result:
return result
if full_text:
result = _parse_json(full_text)
if result and "decision" in result:
logger.info(f"[Kei 넘침 판단] decision={result['decision']}")
return result
logger.warning("[Kei 넘침 판단] JSON 파싱 실패 또는 decision 없음")
return None
logger.warning("Kei API (overflow) 텍스트 추출 실패")
return None
except Exception as e:
logger.warning(f"Anthropic 직접 호출 실패: {e}")
logger.warning(f"Kei API (overflow) 호출 실패: {e}")
return None

View File

@@ -15,8 +15,8 @@ from typing import Any, AsyncIterator
import anthropic
from src.kei_client import classify_content, manual_classify, refine_concepts
from src.design_director import create_layout_concept, LAYOUT_PRESETS, select_preset
from src.kei_client import classify_content, manual_classify, refine_concepts, call_kei_overflow_judgment
from src.design_director import create_layout_concept, LAYOUT_PRESETS, select_preset, _downgrade_fallback
from src.content_editor import fill_content
from src.renderer import render_slide
from src.image_utils import get_image_sizes, embed_images
@@ -55,6 +55,20 @@ async def generate_slide(
analysis = await refine_concepts(content, analysis)
logger.info("1단계-B 완료: 컨셉 구체화")
# 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%} → 첫 꼭지 제목 변경"
)
# 이미지 크기 측정 (base_path 있을 때만)
image_sizes = get_image_sizes(content, base_path)
if image_sizes:
@@ -92,7 +106,9 @@ async def generate_slide(
yield {"event": "progress", "data": "5/5 디자인 팀장이 전체 균형을 검토 중..."}
for review_round in range(MAX_REVIEW_ROUNDS):
review_result = await _review_balance(html, layout_concept, content)
review_result = await _review_balance(
html, layout_concept, content, analysis
)
if not review_result or not review_result.get("needs_adjustment"):
if review_round == 0:
@@ -107,6 +123,31 @@ async def generate_slide(
f"조정 필요 — {issues}"
)
# overflow_detected가 있으면 Kei에게 판단 요청 (Sonnet은 감지만, 판단은 Kei)
overflow_adjs = [
adj for adj in review_result.get("adjustments", [])
if adj.get("action") == "overflow_detected"
]
if overflow_adjs:
overflow_context = _build_overflow_context(
layout_concept, overflow_adjs
)
kei_judgment = await call_kei_overflow_judgment(
overflow_context, content, analysis
)
if kei_judgment is None:
logger.warning("[DOWNGRADE 비상] Kei API 실패 → 기계적 교체")
for page in layout_concept.get("pages", []):
_downgrade_fallback(
page.get("blocks", []), overflow_context
)
else:
_convert_kei_judgment(review_result, kei_judgment)
logger.info(
f"[Kei 넘침 판단] decision={kei_judgment.get('decision')}"
)
layout_concept = await _apply_adjustments(
layout_concept, review_result, content
)
@@ -237,13 +278,15 @@ async def _review_balance(
html: str,
layout_concept: dict[str, Any],
content: str,
analysis: dict[str, Any] | None = None,
) -> dict[str, Any] | None:
"""5단계: 디자인 팀장이 1차 조립 결과를 재검토한다.
"""5단계: 디자인 팀장이 조립 결과를 재검토한다.
HTML 코드 기반으로 구조적 점검:
HTML 코드 기반으로 구조적 점검 + 높이 넘침 감지:
- 빈 블록 감지
- 블록 간 채움 비율 불균형
- 이미지/표 크기 적절성
- 높이 초과 감지 → overflow_detected (Kei 판단 필요)
"""
try:
client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
@@ -259,28 +302,62 @@ async def _review_balance(
f"데이터 {text_len}"
)
# zone 예산 정보 (analysis에서 프리셋 추출)
zone_budget_text = ""
overflow_hint_text = ""
if analysis:
preset_name = select_preset(analysis)
preset = LAYOUT_PRESETS.get(preset_name, {})
zone_lines = [
f"- {name}: ~{z['budget_px']}px (너비 {z['width_pct']}%)"
for name, z in preset.get("zones", {}).items()
]
zone_budget_text = (
"\n\n## zone별 높이 예산\n" + "\n".join(zone_lines)
)
# Stage 2에서 감지한 예상 overflow 힌트
overflow_hint = layout_concept.get("overflow", [])
if overflow_hint:
hint_lines = [
f"- {o['area']}: 예상 {o['total_px']}px > 예산 {o['budget_px']}px "
f"(+{o['overflow_px']}px 초과)"
for o in overflow_hint
]
overflow_hint_text = (
"\n\n## 높이 초과 힌트 (2단계 예상치, 참고용)\n"
+ "\n".join(hint_lines)
)
system = (
"당신은 디자인 팀장이다. 1차 조립 결과(HTML)를 검토하여 균형을 점검한다.\n\n"
"당신은 디자인 팀장이다. 조립 결과(HTML)를 검토하여 균형과 높이 제약을 점검한다.\n\n"
"## 점검 항목\n"
"1. 빈 블록: 데이터가 없거나 극히 적은 블록\n"
"2. 채움 불균형: 한 블록은 빽빽하고 다른 블록은 비어있음\n"
"3. 이미지/표: 너무 작거나 큰 것은 없는지\n"
"4. 전체 정보량: 한 페이지에 너무 많거나 적은지\n"
"5. HTML 구조: 블록이 영역 안에 잘 배치되었는지\n\n"
"5. HTML 구조: 블록이 영역 안에 잘 배치되었는지\n"
"6. 높이 초과: 각 zone의 블록+텍스트가 예산을 초과하는가?\n"
" - 텍스트 양/블록 수를 보고 판단\n"
" - shrink로 해결 가능하면 shrink 사용\n"
" - 불가능 (콘텐츠가 본질적으로 큼) → overflow_detected\n\n"
"## 조정 action 설명\n"
"- expand: 텍스트를 늘린다. target_ratio로 얼마나 늘릴지 지정 (예: 1.3 = 30% 증가)\n"
"- shrink: 텍스트를 줄인다. target_ratio로 얼마나 줄일지 지정 (예: 0.7 = 30% 감소)\n"
"- rewrite: 텍스트를 완전히 재작성한다. detail에 재작성 방향 명시.\n\n"
"- expand: 텍스트를 늘린다. target_ratio로 지정 (예: 1.3 = 30% 증가)\n"
"- shrink: 텍스트를 줄인다. target_ratio로 지정 (예: 0.7 = 30% 감소)\n"
"- rewrite: 텍스트를 완전히 재작성한다. detail에 방향 명시.\n"
"- overflow_detected: 높이 초과로 콘텐츠 판단 필요. 해당 zone과 초과 블록을 detail에 명시.\n\n"
"## 출력 형식 (JSON만)\n"
'{"needs_adjustment": true/false, '
'"issues": ["이슈1", "이슈2"], '
'"adjustments": [{"block_area": "...", "action": "expand|shrink|rewrite", '
'"adjustments": [{"block_area": "...", "action": "expand|shrink|rewrite|overflow_detected", '
'"target_ratio": 1.3, "detail": "..."}]}'
)
user_prompt = (
f"## 1차 조립 HTML\n{html}\n\n"
f"## 조립 HTML\n{html}\n\n"
f"## 블록별 데이터 양\n" + "\n".join(block_summary) +
zone_budget_text +
overflow_hint_text +
f"\n\n## 레이아웃 구조\n"
f"페이지 수: {len(layout_concept.get('pages', []))}\n"
f"총 블록 수: {sum(len(p.get('blocks', [])) for p in layout_concept.get('pages', []))}\n\n"
@@ -344,11 +421,105 @@ async def _apply_adjustments(
block["reason"] = f"재작성: {detail}"
logger.info(f"조정: {area} → rewrite ({detail})")
elif action == "kei_trim":
max_chars = adj.get("max_chars", 200)
if "char_guide" not in block:
block["char_guide"] = {}
for key in block.get("char_guide", {}):
block["char_guide"][key] = min(
block["char_guide"][key], max_chars
)
if not block["char_guide"]:
block["char_guide"] = {"text": max_chars}
logger.info(
f"조정: {area} → kei_trim max_chars={max_chars} "
f"({detail})"
)
elif action == "kei_restructure":
block["detail_target"] = True
if "data" in block:
del block["data"]
block["reason"] = f"재구성: {detail}"
logger.info(
f"조정: {area} → kei_restructure (detail_target)"
)
# 조정된 가이드로 재편집
layout_concept = await fill_content(content, layout_concept)
return layout_concept
def _build_overflow_context(
layout_concept: dict[str, Any],
overflow_adjs: list[dict],
) -> list[dict]:
"""Sonnet이 감지한 overflow_detected를 Kei에게 전달할 형태로 변환한다.
실제 채워진 블록 데이터(텍스트)를 포함하여 Kei가 판단할 수 있도록 한다.
"""
overflows = []
for adj in overflow_adjs:
area = adj.get("block_area", "")
# 해당 zone의 블록 정보 + 실제 텍스트 추출
area_blocks = []
for page in layout_concept.get("pages", []):
for block in page.get("blocks", []):
if block.get("area") == area:
data = block.get("data", {})
text_preview = json.dumps(data, ensure_ascii=False)[:300]
area_blocks.append({
"type": block.get("type", ""),
"purpose": block.get("purpose", ""),
"topic_id": block.get("topic_id"),
"text_preview": text_preview,
})
overflows.append({
"area": area,
"detail": adj.get("detail", ""),
"blocks": area_blocks,
})
return overflows
def _convert_kei_judgment(
review_result: dict[str, Any],
kei_judgment: dict[str, Any],
) -> None:
"""Kei의 trim/restructure 판단을 review_result.adjustments에 반영한다.
기존 overflow_detected 항목을 kei_trim 또는 kei_restructure로 교체.
"""
decision = kei_judgment.get("decision", "")
new_adjs = []
for adj in review_result.get("adjustments", []):
if adj.get("action") == "overflow_detected":
# overflow_detected → Kei 판단으로 교체
if decision == "trim":
for target in kei_judgment.get("trim_targets", []):
new_adjs.append({
"block_area": adj.get("block_area", ""),
"action": "kei_trim",
"max_chars": target.get("max_chars", 200),
"topic_id": target.get("topic_id"),
"detail": target.get("reason", ""),
})
elif decision == "restructure":
for tid in kei_judgment.get("detail_topics", []):
new_adjs.append({
"block_area": adj.get("block_area", ""),
"action": "kei_restructure",
"topic_id": tid,
"detail": kei_judgment.get("reason", ""),
})
else:
# 기존 expand/shrink/rewrite는 그대로 유지
new_adjs.append(adj)
review_result["adjustments"] = new_adjs
def _parse_json(text: str) -> dict[str, Any] | None:
"""텍스트에서 JSON을 추출한다."""
patterns = [

50
src/sse_utils.py Normal file
View File

@@ -0,0 +1,50 @@
"""SSE 스트리밍 공통 유틸리티.
persona_agent의 SSE 이벤트를 수신하여 토큰을 수집한다.
kei_client, content_editor, design_director에서 공통 사용.
"""
from __future__ import annotations
import json
import logging
import httpx
logger = logging.getLogger(__name__)
async def stream_sse_tokens(response: httpx.Response) -> str:
"""SSE 스트리밍 응답에서 토큰을 실시간 수집한다.
persona_agent의 SSE 이벤트:
- token: 텍스트 토큰 수집
- done: 완료, 중단
- error: 에러, 즉시 중단
- planning/planning_done/research_progress/warning: 스킵
"""
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)

View File

@@ -1,4 +1,4 @@
# 블록 라이브러리 인덱스 (46개)
# 블록 라이브러리 인덱스 (38개)
디자인 팀장이 콘텐츠에 맞는 블록을 선택할 때 참조하는 라이브러리.
각 카테고리 안에 변형이 여러 개 있으며, 콘텐츠 성격에 따라 적절한 변형을 선택한다.
@@ -19,12 +19,11 @@
---
## 📁 cards/ (10개) — 카드 계열
## 📁 cards/ (9개) — 카드 계열
| 파일 | 설명 | 언제 사용 |
|------|------|---------|
| `card-image-3col.html` | 이미지(160px) + 색상 제목 + 영문 + 불릿 (3열) | 단계별 설명에 이미지 핵심 |
| `card-text-grid.html` | 파란 액센트 + 제목 + 뱃지 + 설명 + 출처 (2~4열) | 용어 정의, 텍스트만 나열 |
| `card-dark-overlay.html` | 다크 이미지 배경 + 흰 제목 + 짧은 설명 (3~5열) | 키워드 시각 강조, 임팩트 |
| `card-tag-image.html` | 색상 태그 라벨 + 이미지 + 제목 + 설명 (3열) | 카테고리별 분류 (제조/건축/토목) |
| `card-icon-desc.html` | 큰 이모지 아이콘 + 제목 + 설명 (2~4열) | 기능/특성/장점 아이콘 나열 |
@@ -46,7 +45,7 @@
---
## 📁 visuals/ (10개) — 시각 요소 (**SVG**)
## 📁 visuals/ (6개) — 시각 요소 (**SVG**)
**SVG 노하우:**
- `<text>` = 원 좌표와 같은 공간 → 위치 100% 정확
@@ -63,21 +62,15 @@
| `process-horizontal.html` | 파란 번호 원 + 카드 + → 화살표 (가로) | 논리적 프로세스 흐름 |
| `flow-arrow-horizontal.html` | 색상 캡슐 + 화살표 (SVG, 컴팩트) | 기술 발전/전환 흐름 간결하게 |
| `keyword-circle-row.html` | SVG 원형 안 큰 글자 + 라벨 + 설명 | 약어 풀이 (G-S-I-M) |
| `layer-diagram.html` | SVG 겹친 사다리꼴 레이어 (3D) | 기술 스택/계층 구조 |
| `timeline-vertical.html` | 세로 선 + SVG 마커 + 연도+제목+설명 | 연혁, 로드맵 (4개+ 이벤트) |
| `timeline-horizontal.html` | SVG 가로 선 + 마커 + 연도+제목 | 짧은 일정 (3~5개, 컴팩트) |
| `pyramid-hierarchy.html` | SVG 위→아래 넓어지는 사각형 | 위계, 우선순위 (좁은→넓은) |
---
## 📁 emphasis/ (13개) — 강조, 인용, 결론
## 📁 emphasis/ (10개) — 강조, 인용, 결론
| 파일 | 설명 | 언제 사용 |
|------|------|---------|
| `quote-left-border.html` | 좌측 빨간 라인 + 연한 배경 + 인용+출처 | 짧은 인용, 문제 제기 |
| `quote-big-mark.html` | ❝❞ 큰따옴표 장식 + 인용+출처 | 임팩트 인용, 핵심 발언 |
| `quote-question.html` | 파란 배경+테두리 + 큰 질문 텍스트 | 독자에게 질문, 전환점 |
| `conclusion-accent-bar.html` | 회색 배경 + 좌측 파란 라인 + 결론 | 페이지 하단 핵심 한 줄 |
| `comparison-2col.html` | 좌 파란 vs 우 빨간 헤더 + 본문 | A vs B 직접 비교 |
| `banner-gradient.html` | 파란 그라데이션 배너 + 중앙 흰 텍스트 | 섹션 구분, 핵심 선언 |
| `dark-bullet-list.html` | 짙은 남색 배경 + 파란 제목 + 흰 불릿 | 핵심 포인트 강조 (무게감) |
@@ -86,7 +79,6 @@
| `callout-warning.html` | 빨간 배경+테두리 + 아이콘 + 제목+설명 | 문제점, 주의, 잘못된 접근 |
| `tab-label-row.html` | 가로 탭 버튼 (선택됨=색상, 나머지=회색) | 카테고리 전환/분류 표시 |
| `divider-text.html` | 좌우 회색 선 + 중앙 텍스트 | 가벼운 섹션 구분, 휴식점 |
| `details-block.html` | `<details>/<summary>` 접기/펼치기 | 상세 콘텐츠, 자세히보기 |
---

View File

@@ -99,7 +99,7 @@ blocks:
이미지가 핵심인 경우.
'
not_for: '이미지 없이 텍스트만 → card-text-grid 사용. 키워드+짧은 설명만 강조 → card-dark-overlay 사용.
not_for: '이미지 없이 텍스트만 → card-icon-desc 사용. 키워드+짧은 설명만 강조 → card-dark-overlay 사용.
2개 비교 → compare-pill-pair + comparison-table 조합 사용.
'
@@ -116,7 +116,7 @@ blocks:
나열.
'
not_for: '긴 설명(3줄 이상) → card-text-grid 사용. 이미지가 핵심(크게 보여야 함) → card-image-3col 사용.
not_for: '긴 설명(3줄 이상) → card-icon-desc 사용. 이미지가 핵심(크게 보여야 함) → card-image-3col 사용.
'
slots:
@@ -131,7 +131,7 @@ blocks:
when: '카테고리별 분류가 핵심일 때. 태그로 구분. 예: 제조업(파란) / 건축(초록) / 인프라·토목(빨간)
'
not_for: '태그 불필요 → card-image-3col 사용. 이미지 없음 → card-text-grid 사용.
not_for: '태그 불필요 → card-image-3col 사용. 이미지 없음 → card-icon-desc 사용.
'
slots:
@@ -207,7 +207,7 @@ blocks:
when: 'KPI, 성과 수치, 목표 달성률, 비용 절감율. 예: 30% 절감 / 40% 감소 / 220명+ 인력
'
not_for: '숫자가 아닌 텍스트 → card-text-grid 사용.
not_for: '숫자가 아닌 텍스트 → card-icon-desc 사용.
'
slots:
@@ -223,7 +223,7 @@ blocks:
4.교육
'
not_for: '순서 없음 → card-text-grid 사용. 이미지 포함 단계 → card-step-vertical 사용. 가로 흐름 →
not_for: '순서 없음 → card-icon-desc 사용. 이미지 포함 단계 → card-step-vertical 사용. 가로 흐름 →
process-horizontal 사용.
'
@@ -308,7 +308,7 @@ blocks:
when: '섹션 전환점 키워드 강조. 아래에 카드/표 올 때 주제 선언.
'
not_for: '본문 텍스트 → topic-header 계열. 결론 → conclusion-accent-bar.
not_for: '본문 텍스트 → topic-header 계열. 결론 → banner-gradient.
'
slots:
@@ -342,7 +342,7 @@ blocks:
when: '논리적 순서를 가로로 (1→2→3→4). 프로세스 흐름.
'
not_for: '시간 기반(연도) → timeline 사용. 세로 나열 → card-numbered 사용.
not_for: '시간 기반(연도) → process-horizontal 사용. 세로 나열 → card-numbered 사용.
'
slots:
@@ -373,7 +373,7 @@ blocks:
+ M(Model)
'
not_for: '아이콘+설명 → card-icon-desc 사용. 용어 정의 → card-text-grid 사용.
not_for: '아이콘+설명 → card-icon-desc 사용. 용어 정의 → card-icon-desc 사용.
'
slots:
@@ -388,7 +388,7 @@ blocks:
when: '임팩트 있는 문제 제기. 시각적으로 인용임을 명확히.
'
not_for: '짧은 인용 → quote-left-border. 질문 → quote-question.
not_for: '짧은 인용(1~2줄) → quote-question. 질문 형태 → quote-question.
'
slots:
@@ -404,7 +404,7 @@ blocks:
when: '독자에게 질문. 문제 인식 유도, 전환점. 예: ''지금의 방식으로도 가능할까?''
'
not_for: '인용(출처) → quote-left-border. 결론 → conclusion-accent-bar.
not_for: '인용(출처) → quote-big-mark. 결론 → banner-gradient.
'
slots:
@@ -440,7 +440,7 @@ blocks:
when: '섹션 구분, 핵심 선언, 강조 문구.
'
not_for: '하단 결론 → conclusion-accent-bar. 인용 → quote 계열.
not_for: '하단 결론 → banner-gradient. 인용 → quote 계열.
'
slots:
@@ -456,7 +456,7 @@ blocks:
when: '핵심 포인트를 짙은 배경 위에 강조. 시각적 무게감.
'
not_for: '밝은 배경 → card-text-grid 또는 card-numbered.
not_for: '밝은 배경 → card-icon-desc 또는 card-numbered.
'
slots:
@@ -537,7 +537,7 @@ blocks:
when: '섹션 구분, 주제 전환점에 가벼운 구분. 예: ── 핵심 요약 ──
'
not_for: '강한 구분 → section-header-bar. 결론 → conclusion-accent-bar.
not_for: '강한 구분 → section-header-bar. 결론 → banner-gradient.
'
slots: