+```
+
+**design_director.py — sidebar 블록에 column_override 주입:**
+`_validate_height_budget()` 함수 내, 금지 블록 처리 이후에 삽입:
+
+```python
+# sidebar 카드 블록 1열 강제 (J-6)
+CARD_BLOCKS = {
+ "card-tag-image", "card-icon-desc", "card-image-3col",
+ "card-dark-overlay", "card-compare-3col", "card-image-round",
+ "card-stat-number",
+}
+for block in blocks:
+ if block.get("area") == "sidebar" and block.get("type") in CARD_BLOCKS:
+ if "data" not in block:
+ block["data"] = {}
+ block["data"]["column_override"] = 1
+```
+
+**충돌:** 없음. `column_override`는 새 키. `default(cards|length)`로 body에서는 기존대로.
+**회귀:** 없음. 기존 렌더링 동작 변경 없음.
+
+---
+
+### J-7: Stage 5 최종 검토 Kei 전환
+
+**방법:** `kei_client.py`에 `call_kei_final_review()` 신규 함수 추가 + `pipeline.py`에서 호출
+
+**kei_client.py 신규 함수:**
+
+```python
+KEI_REVIEW_PROMPT = """당신은 11년 경력의 기획 실장이다. 디자인 팀장이 조립한 슬라이드를 최종 검수한다.
+
+## 검수 관점
+1. 핵심 메시지(core_message)가 시각적으로 명확히 전달되는가?
+2. 콘텐츠 흐름(문제제기→사례→정의→관계→결론)이 블록 배치와 일치하는가?
+3. 각 블록이 해당 꼭지의 purpose에 적합한가?
+4. 중요한 내용이 빠지거나 과도하게 축소되지 않았는가?
+5. 높이 초과: 각 zone의 블록+텍스트가 예산을 초과하는가?
+ - 텍스트 축약으로 해결 가능 → shrink
+ - 콘텐츠가 본질적으로 큼 → overflow_detected
+
+## 조정 action
+- expand: 텍스트 늘림 (target_ratio, 예: 1.3)
+- shrink: 텍스트 줄임 (target_ratio, 예: 0.7)
+- rewrite: 텍스트 재작성 (detail에 방향)
+- overflow_detected: 높이 초과, 콘텐츠 판단 필요 (zone과 블록 명시)
+
+## 출력 (JSON만)
+{"needs_adjustment": true/false, "issues": ["이슈1"], "adjustments": [{"block_area": "...", "action": "...", "target_ratio": 1.3, "detail": "..."}]}
+"""
+
+async def call_kei_final_review(
+ html: str,
+ block_summary: list[str],
+ zone_budget_text: str,
+ overflow_hint_text: str,
+ analysis: dict[str, Any],
+) -> dict[str, Any] | None:
+ """Kei(Opus)가 최종 검수한다.
+
+ 반드시 Kei API 경유. Sonnet 사용 절대 금지.
+ session_id: design-agent-final-review
+ """
+ kei_url = getattr(settings, "kei_api_url", "http://localhost:8000")
+
+ core_message = analysis.get("core_message", "") if analysis else ""
+ topics_summary = ""
+ if analysis:
+ topics_summary = "\n".join(
+ f"- 꼭지 {t.get('id')}: {t.get('title', '')} [{t.get('purpose', '')}]"
+ for t in analysis.get("topics", [])
+ )
+
+ prompt = (
+ KEI_REVIEW_PROMPT + "\n\n"
+ f"## 핵심 메시지\n{core_message}\n\n"
+ f"## 꼭지 목록\n{topics_summary}\n\n"
+ f"## 블록별 데이터 양\n" + "\n".join(block_summary) +
+ zone_budget_text +
+ overflow_hint_text +
+ f"\n\n## 조립 HTML (요약)\n{html[:3000]}\n\n"
+ f"위 결과물을 검수하고 조정이 필요한지 판단해. JSON만."
+ )
+
+ 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-final-review",
+ "mode_hint": "chat",
+ },
+ timeout=None,
+ ) as response:
+ if response.status_code != 200:
+ logger.warning(f"Kei 최종 검수 HTTP {response.status_code}")
+ return None
+ full_text = await stream_sse_tokens(response)
+
+ if full_text:
+ result = _parse_json(full_text)
+ if result and "needs_adjustment" in result:
+ logger.info(f"[Kei 최종 검수] needs_adjustment={result['needs_adjustment']}")
+ return result
+ return None
+ except Exception as e:
+ logger.warning(f"Kei 최종 검수 실패: {e}")
+ return None
+```
+
+**pipeline.py 변경:**
+- import: `from src.kei_client import ... call_kei_final_review`
+- `_review_balance()` 내부: Sonnet API 호출 → `call_kei_final_review()` 호출로 교체
+- 기존 `block_summary`, `zone_budget_text`, `overflow_hint_text` 구성 로직은 유지 (pipeline에 남음)
+- `anthropic.AsyncAnthropic` + `client.messages.create` 코드 제거
+- `import anthropic`은 Stage 4(`_adjust_design`)에서 아직 사용하므로 유지
+
+**출력 스키마:** 기존과 100% 동일 → `_apply_adjustments()`, `_convert_kei_judgment()` 변경 불필요.
+**overflow 처리:** 기존 Stage 5 루프의 overflow_detected → Kei overflow 호출 흐름 그대로 유지.
+
+---
+
+## 실행 프로세스 (의존 관계 + 순서)
+
+```
+Phase J-A: 팀장 권한 제한 + 가이드 수정
+├── J-1: STEP_B_PROMPT Opus 존중 규칙 (design_director.py 744행)
+├── J-2: section-header-bar body 금지 (BODY_FORBIDDEN_MAP + 교체 로직)
+├── J-3a: purpose 가이드 수정 (504, 506행 + PURPOSE_FALLBACK)
+├── J-3b: catalog.yaml 수정 (376행)
+└── J-6: sidebar 1열 강제 (템플릿 2개 + design_director 주입)
+ ↓ (J-A 완료 후)
+Phase J-B: 편집자 강화
+└── J-4: source 슬롯 금지 규칙 (EDITOR_PROMPT)
+ ↓ (J-B 완료 후)
+Phase J-C: 최종 검토 Kei 전환
+└── J-7: call_kei_final_review() 신규 + pipeline Stage 5 교체
+ ↓
+검증: import + 서버 기동 + 결과물 비교
+```
+
+### Phase J-A 내부 의존 관계
+- J-2는 `_validate_height_budget()` 수정 → J-6도 같은 함수 안에 삽입 → **J-2 먼저, J-6 이후**
+- J-1, J-3a, J-3b는 서로 독립 → 순서 무관
+
+### Phase J-C 의존
+- J-7은 J-A/J-B와 독립이지만, **J-A 수정된 결과물로 검증해야 의미** → J-A/J-B 완료 후 실행
+
+---
+
+## 변경 파일 총괄
+
+| 파일 | 항목 | 변경 성격 |
+|------|------|----------|
+| `src/design_director.py` | J-1, J-2, J-3a, J-6 | 프롬프트 + BODY_FORBIDDEN_MAP + PURPOSE_FALLBACK + sidebar column_override |
+| `src/content_editor.py` | J-4 | EDITOR_PROMPT에 source 규칙 추가 |
+| `src/kei_client.py` | J-7 | KEI_REVIEW_PROMPT + call_kei_final_review() 신규 |
+| `src/pipeline.py` | J-7 | _review_balance() 내부 Sonnet → Kei 교체 + import 추가 |
+| `templates/catalog.yaml` | J-3b | not_for 1건 수정 |
+| `templates/blocks/cards/card-tag-image.html` | J-6 | column_override 지원 |
+| `templates/blocks/cards/card-icon-desc.html` | J-6 | column_override 지원 |
+
+---
+
+## 충돌/회귀/오류 최종 검증
+
+| 항목 | 충돌 | 회귀 | Kei/Sonnet | 하드코딩 | 단발성 | 주의 사항 |
+|------|:---:|:---:|:----------:|:------:|:-----:|----------|
+| J-1 | 없음 | 없음 | Sonnet(기존) | 없음 | 아님 | — |
+| J-2 | **주의** | 없음 | — | 상수 | 아님 | 루프 중 삭제 → 별도 필터링 + zone_blocks 재구성 |
+| J-3a | 없음 | I-1과 다른 목적 | Sonnet(기존) | 없음 | 아님 | PURPOSE_FALLBACK도 같이 수정 |
+| J-3b | 없음 | I-2와 다른 목적 | — | 없음 | 아님 | — |
+| J-4 | 없음 | I-5와 보완 | **Kei**(편집자) | 없음 | 아님 | — |
+| J-6 | **주의** | 없음 | — | 범용 키 | 아님 | 템플릿 2개 수정 + data 주입 |
+| J-7 | **주의** | 프로세스 재설계 유지 | **Kei**(신규) | 없음 | 아님 | pipeline import + Sonnet 코드 제거 |
+
+**Sonnet 신규 투입: 0건**
+**Kei API 사용: J-4(기존 편집자), J-7(신규 최종 검수)**
+**하드코딩: 0건**
+**회귀: 0건**
+**단발성: 0건**
+
+---
+
+## 실행 결과 상세
+
+### Phase J-A: 팀장 권한 제한 + 가이드 수정 (5개) ✅
+
+| 항목 | 파일 | 반영 내용 |
+|------|------|----------|
+| J-1 | `src/design_director.py` 744행 | `"Opus 추천이 있으면 참고하되, 최종 선택은 팀장 판단"` → `"Opus 추천 블록을 기본 사용한다. 높이 초과 등 명확한 사유 없이 변경하지 마라. 변경 시 reason에 사유를 반드시 명시하라."` |
+| J-2 | `src/design_director.py` 899행 | `BODY_FORBIDDEN_MAP`에 `"section-header-bar": None` 추가. 금지 블록 처리 로직 변경: `None`이면 교체가 아닌 삭제. `blocks_to_remove` 별도 리스트로 루프 중 삭제 안전 처리. 삭제 후 `zone_blocks` 재구성 추가. |
+| J-3a | `src/design_director.py` 504행, 506행 | purpose 가이드: `근거사례 → card-icon-desc` → `card-numbered`, `용어정의 → card-icon-desc` → `card-numbered, dark-bullet-list`. `PURPOSE_FALLBACK` 892행: `"용어정의": "card-icon-desc"` → `"card-numbered"` |
+| J-3b | `templates/catalog.yaml` 376행 | `not_for: '용어 정의 → card-icon-desc 사용'` → `'용어 정의 → card-numbered 사용'` |
+| J-6 | `templates/blocks/cards/card-tag-image.html` 9행 | `--ct-count: {{ cards\|length }}` → `--ct-count: {{ column_override \| default(cards\|length) }}` |
+| J-6 | `templates/blocks/cards/card-icon-desc.html` 9행 | `--ci-count: {{ cards\|length }}` → `--ci-count: {{ column_override \| default(cards\|length) }}` |
+| J-6 | `src/design_director.py` `_validate_height_budget()` 내 | sidebar 카드 블록에 `block["data"]["column_override"] = 1` 주입. CARD_BLOCKS 상수로 대상 블록 정의. |
+
+### Phase J-B: 편집자 강화 (1개) ✅
+
+| 항목 | 파일 | 반영 내용 |
+|------|------|----------|
+| J-4 | `src/content_editor.py` EDITOR_PROMPT | `## source 슬롯 규칙 (절대 규칙)` 섹션 추가. 출처만 허용, 꼭지 제목/주제어 금지, 없으면 빈 문자열. 올바른/잘못된 예시 포함. Kei API(편집자)에 전달됨. |
+
+### Phase J-C: 최종 검토 Kei 전환 (1개) ✅
+
+| 항목 | 파일 | 반영 내용 |
+|------|------|----------|
+| J-7 | `src/kei_client.py` | `KEI_REVIEW_PROMPT` 상수 신규: 11년 경력 기획 실장 관점, 핵심 메시지 전달/콘텐츠 흐름/purpose 적합성/높이 초과 검수. `call_kei_final_review()` 함수 신규: session_id `"design-agent-final-review"`, Kei API SSE 스트리밍, 출력 스키마 기존과 100% 동일. |
+| J-7 | `src/pipeline.py` import | `call_kei_final_review` import 추가 |
+| J-7 | `src/pipeline.py` `_review_balance()` | Sonnet API(`anthropic.AsyncAnthropic` + `client.messages.create`) 코드 제거. `call_kei_final_review(html, block_summary, zone_budget_text, overflow_hint_text, analysis)` 호출로 교체. block_summary/zone_budget_text/overflow_hint_text 구성 로직은 pipeline에 유지. |
+
+---
+
+## 검증 체크리스트 (실행 완료)
+
+### 팀장 권한 제한
+- [x] J-1: STEP_B_PROMPT에 "Opus 추천 기본 사용, 변경 금지" 명시
+- [x] J-2: BODY_FORBIDDEN_MAP에 section-header-bar: None. 삭제 로직 + zone_blocks 재구성
+- [x] J-3a: purpose 가이드 용어정의/근거사례에서 card-icon-desc 제거 → card-numbered
+- [x] J-3a: PURPOSE_FALLBACK 용어정의 → card-numbered
+- [x] J-3b: catalog.yaml "용어 정의 → card-numbered"
+- [x] J-6: 템플릿 2개 column_override 지원 + sidebar 블록에 column_override=1 주입
+
+### 편집자 강화
+- [x] J-4: EDITOR_PROMPT에 source 슬롯 금지 규칙 추가 (Kei API 편집자 경유)
+
+### 최종 검토 Kei 전환
+- [x] J-7: call_kei_final_review() 함수 신규 (kei_client.py)
+- [x] J-7: _review_balance() → Sonnet 코드 제거, Kei API 호출로 교체
+- [x] J-7: Stage 5에 Sonnet 모델 참조 0건 확인
+
+### 기술 검증
+- [x] 모든 모듈 import 성공
+- [x] FastAPI 앱 로드 성공 (8 routes)
+- [x] BLOCK_SLOTS 38/38, slot_desc 38/38 (Phase I 회귀 없음)
+- [x] BODY_FORBIDDEN_MAP: section-header-bar=None 확인
+- [x] PURPOSE_FALLBACK 용어정의=card-numbered 확인
+
+### 절대 규칙 준수
+- [x] Sonnet 신규 투입 0건 — Stage 5가 Kei API만 사용
+- [x] 하드코딩 0건
+- [x] 단발성 수정 0건
+- [x] Phase I 회귀 0건
+- [x] persona_agent 수정 0건
+
+---
+
+## 이력
+
+| 날짜 | 내용 |
+|------|------|
+| 2026-03-26 | Phase I 완료 후 결과물 3회 비교. 7개 문제 진단. Phase J 계획 수립. |
+| 2026-03-26 | 기술 조사 + 충돌/회귀/오류 검토 완료. 구현 상세 + 실행 프로세스 확정. |
+| 2026-03-26 | **Phase J 실행 완료.** 7개 항목 전수 구현. 검증 전항목 통과. Stage 5 Kei 전환 확인. |
diff --git a/IMPROVEMENT-PHASE-K.md b/IMPROVEMENT-PHASE-K.md
new file mode 100644
index 0000000..6da34ec
--- /dev/null
+++ b/IMPROVEMENT-PHASE-K.md
@@ -0,0 +1,445 @@
+# Phase K: communicative role 기반 시각적 위계 + 콘텐츠 시퀀싱
+
+> 상태: ✅ 완료 — purpose별 분량 원칙은 Phase O에서 동적 계산(_max_chars_total)으로 발전.
+>
+> Phase I(코드 정합성) + Phase J(블록 선택 권한) 이후에도 결과물 품질이 개선되지 않은 근본 원인.
+> **핵심: purpose(communicative role)를 분류하고도, 시각적 결과에 반영하지 않았음.**
+> 사용자가 반복 요청한 콘텐츠 구조 흐름이 Phase J에서 누락됨. 이번에 전부 반영.
+>
+> **후속 변경 (Phase O):**
+> - purpose별 분량 제약(문제제기 100자 등) → 컨테이너 크기 기반 동적 계산으로 대체
+> - catalog.yaml schema의 body/sidebar 글자수 → ref_chars(참고값) + max_lines/font_size(디자인 스펙)으로 분리
+
+---
+
+## 사용자 반복 요청 (Phase I 이전부터)
+
+```
+"상단에 오해하고 잘못되었다.
+→ 그래서 보니 혼용하는 사례들이 있더라.
+→ 여기랑 여기 등을 구체적 사례들을 봐라.
+→ 사실은 이런것이다!! (이게 구조화가 되어야 하는것 아닌가?) ← 이게 핵심
+→ 그리고 해당하는 내용에 대한 개념 정의
+→ 마지막 핵심 문장 딱 하나!"
+
+"관련 용어들의 정의만 시각적으로 오른쪽에 배치되고,
+위에서부터
+배경 & 증빙 사례 → 그래서 이거다! (더 자세히 보러가기) → 이 슬라이드의 핵심 키워드!!
+우측에 관련 용어에 대한 정의가 구조화되어 시각적으로 잘되어야지."
+```
+
+---
+
+## 참고 연구
+
+| 프로젝트 | 핵심 접근 | 우리 적용점 |
+|---------|----------|-----------|
+| Presenton | 블록별 JSON 스키마(min/maxLength)로 overflow 원천 차단 | purpose별 분량 제약 (K-4) |
+| PPTAgent | communicative role 분류 후 레이아웃 매칭 | purpose → 시각적 위계 매핑 (K-1) |
+| Auto-Slides | 인지 부하 이론 기반 콘텐츠 시퀀싱 | purpose 기반 인지 흐름 순서 (K-2) |
+
+공통 결론: **"communicative role을 먼저 분류하지 않고 레이아웃부터 선택하는 것이 실패의 근본 원인"**
+
+우리 파이프라인은 role(purpose)을 분류하지만, **그것이 시각적 결과에 반영되지 않는 것**이 문제.
+
+---
+
+## 스크린샷에서 확인된 실제 문제
+
+1. "용어간 상호관계" 4줄 불릿이 body에서 가장 크게 차지 — 핵심이 아닌데 주인공
+2. DX vs BIM 비교표가 **화면 밖으로 잘림** — 헤더만 보이고 내용 행 안 보임
+3. sidebar 혼용 사례 3열 카드가 파랑/초록/주황으로 과도하게 강조
+4. sidebar 용어 정의가 장황하게 나열
+5. 비교표에 "왜 비교하는지" 맥락 안내 없음
+
+---
+
+## 변경 항목 (8건)
+
+### K-1: purpose → 시각적 위계 매핑
+
+**지금:** 모든 purpose가 동일한 크기의 블록으로 렌더링. 핵심전달이든 근거사례든 같은 medium 블록.
+**변경:** purpose별 시각적 비중 정의.
+
+| purpose | 시각적 비중 | body 내 공간 비율 |
+|---------|-----------|----------------|
+| 핵심전달 | **최대** — body의 주인공 | 40-60% |
+| 문제제기 | 간결 — compact 블록 | 15-20% |
+| 근거사례 | 보조 — 간결 요약 또는 sidebar | 10-15% |
+| 용어정의 | sidebar 참조 — body에서 빠짐 | sidebar 전용 |
+| 결론강조 | footer 1줄 | footer 전용 |
+
+**반영 위치:**
+- KEI_PROMPT (kei_client.py): Kei가 꼭지 설계 시 비중 명시
+- STEP_B_PROMPT (design_director.py): 팀장이 블록 크기를 purpose 비중에 맞춤
+
+---
+
+### K-2: purpose 기반 인지 흐름 순서
+
+**지금:** Kei가 꼭지를 추출하지만 body 내 배치 순서를 Sonnet이 자유 결정.
+**변경:** purpose가 인지 흐름 순서를 결정. Kei가 순서를 명시하고 팀장은 존중.
+
+**인지 흐름 원칙 (하드코딩 아닌 원칙):**
+- 문제/배경이 먼저 → 왜 이걸 봐야 하는지
+- 근거/사례가 다음 → 그 문제의 증거
+- 핵심 내용이 가장 크게 → 그래서 이거다!
+- 결론이 마지막 → 기억할 한 줄
+
+**반영 위치:**
+- KEI_PROMPT: "핵심전달이 body의 중심에 오도록 순서를 설계하라. 문제제기와 근거사례는 핵심전달을 위한 도입부이다."
+- 콘텐츠 유형에 따라 Kei가 판단 — 모든 콘텐츠에 동일 순서 강제 아님
+
+---
+
+### K-3: purpose별 허용/금지 블록
+
+**지금:** purpose 가이드가 부적절한 블록을 추천하거나, Sonnet이 purpose와 무관하게 선택.
+**변경:** purpose별 허용 블록 + 금지 블록을 명확히 정의.
+
+| purpose | 허용 블록 | 금지 블록 |
+|---------|----------|----------|
+| 문제제기 | quote-big-mark, callout-warning, quote-question | 비교 블록, 카드 블록 |
+| 근거사례 | card-tag-image(sidebar), card-numbered, dark-bullet-list | 비교표 (근거에 비교표 쓰면 핵심과 혼동) |
+| 핵심전달 | compare-2col-split, comparison-2col, compare-3col-badge, topic-left-right | card-icon-desc (이모지), quote 계열 |
+| 용어정의 | card-numbered, dark-bullet-list (sidebar 전용) | 비교 블록, 시각화 블록 |
+| 결론강조 | banner-gradient | 나머지 전부 |
+
+**반영 위치:**
+- STEP_B_PROMPT purpose 가이드 (design_director.py)
+- catalog.yaml when/not_for 보강
+
+---
+
+### K-4: purpose별 분량 제약 (min/max)
+
+**지금:** slot_desc에 슬롯 의미만 있고 분량 제약 없음. 편집자가 자유롭게 분량 결정.
+**변경:** purpose별 분량 가이드.
+
+| purpose | 분량 가이드 | 이유 |
+|---------|-----------|------|
+| 문제제기 | max 100자 (2-3줄) | 간결하게. 도입부. |
+| 근거사례 | max 150자 (핵심만) | 상세는 자세히보기 또는 sidebar. |
+| 핵심전달 | 200-400자 (충분히 구조화) | 주인공이니 충분한 공간. |
+| 용어정의 | 각 용어 max 50자 | sidebar에서 짧게. |
+| 결론강조 | max 40자 (1문장) | 기억할 한 줄. |
+
+**반영 위치:**
+- EDITOR_PROMPT (content_editor.py): purpose별 분량 원칙
+- char_guide: Kei가 꼭지 설계 시 purpose에 따라 char_guide 제안
+
+---
+
+### K-5: sidebar column_override 보존
+
+**지금:** Stage 3(fill_content)에서 data를 통째로 덮어쓰면서 column_override 소실.
+**변경:** data 덮어쓸 때 column_override 등 메타 키 보존.
+
+**반영 위치:** content_editor.py fill_content() 내 data 매칭 로직
+
+---
+
+### K-6: sidebar 시각적 무게 조절
+
+**지금:** card-tag-image가 파랑/초록/주황 태그로 본문보다 눈에 띔. 배경 증빙인데 주인공처럼 보임.
+**변경:**
+- sidebar용 블록은 compact + 저채도로 시각적 무게 낮춤
+- Kei가 "보조 참조"로 분류한 꼭지는 편집자가 분량을 줄이고 팀장이 compact 블록 선택
+- card-tag-image 대신 card-numbered(세로 리스트)를 sidebar 기본으로
+
+**반영 위치:**
+- STEP_B_PROMPT: "sidebar 블록은 본문보다 시각적 무게가 낮아야 한다"
+- KEI_PROMPT: sidebar 꼭지는 분량을 간결하게
+
+---
+
+### K-7: Kei 검수에 구조 흐름 검증 추가
+
+**지금:** Kei 검수가 높이 초과/채움 균형만 봄. "핵심전달이 주인공인가?"를 안 봄.
+**변경:** KEI_REVIEW_PROMPT에 추가 검수 항목:
+
+- 핵심전달 purpose의 꼭지가 body에서 가장 큰 시각적 비중을 차지하는가?
+- 문제제기가 간결한가? (100자 이내)
+- 용어정의가 sidebar에 있는가? body를 차지하고 있지 않은가?
+- 핵심전달 블록이 화면 안에 보이는가? (잘리지 않는가?)
+
+**반영 위치:** KEI_REVIEW_PROMPT (kei_client.py)
+
+---
+
+### K-8: 비교 블록 맥락 안내
+
+**지금:** 비교표가 "DX 구분 BIM" 헤더만으로 등장 → 왜 비교하는지 모름.
+**변경:**
+- 핵심전달로 비교표를 사용할 때, Kei가 "비교 목적"을 summary로 제공
+- 편집자가 비교표 위에 1줄 안내 텍스트를 배치하거나, compare-pill-pair를 헤더로 선행
+
+**반영 위치:**
+- KEI_PROMPT: 핵심전달이 비교 구조일 때 "비교 목적"을 명시하라
+- EDITOR_PROMPT: 비교 블록의 첫 행에 비교 목적 요약 포함
+
+---
+
+## 반영 파일 총괄
+
+| 파일 | 항목 | 변경 성격 |
+|------|------|----------|
+| `src/kei_client.py` KEI_PROMPT | K-1, K-2, K-6, K-8 | purpose별 비중 + 인지 흐름 원칙 + sidebar 간결 + 비교 목적 |
+| `src/kei_client.py` KEI_REVIEW_PROMPT | K-7 | 구조 흐름 검수 항목 추가 |
+| `src/design_director.py` STEP_B_PROMPT | K-1, K-3, K-6 | purpose별 시각적 위계 + 허용/금지 블록 + sidebar 무게 |
+| `src/content_editor.py` EDITOR_PROMPT | K-4, K-8 | purpose별 분량 원칙 + 비교 맥락 안내 |
+| `src/content_editor.py` fill_content() | K-5 | column_override 보존 |
+| `templates/catalog.yaml` | K-3 | when/not_for 보강 (선택적) |
+
+---
+
+## 실행 순서
+
+### K-Step 1: 콘텐츠 설계 (가장 중요 — 이것만 되면 비교표 잘림 해결)
+
+1. K-1: KEI_PROMPT에 purpose별 시각적 비중 원칙
+2. K-2: KEI_PROMPT에 인지 흐름 순서 원칙
+3. K-4: EDITOR_PROMPT에 purpose별 분량 제약
+
+### K-Step 2: 블록 선택 정확성
+
+4. K-3: STEP_B_PROMPT purpose별 허용/금지 블록
+5. K-6: STEP_B_PROMPT + KEI_PROMPT sidebar 시각적 무게
+6. K-8: KEI_PROMPT + EDITOR_PROMPT 비교 맥락 안내
+
+### K-Step 3: 코드 + 검수
+
+7. K-5: content_editor.py column_override 보존
+8. K-7: KEI_REVIEW_PROMPT 구조 흐름 검수
+
+---
+
+## 이것이 하드코딩이 아닌 이유
+
+- "문제제기 → 근거 → 핵심 → 결론" 순서를 **강제하지 않음**
+- Kei에게 **원칙**을 줌: "핵심전달이 주인공이어야 한다", "문제제기는 도입부이므로 간결하게"
+- 콘텐츠에 따라 Kei가 **순서와 비중을 판단** — 프로세스 설명이면 프로세스 흐름, 비교면 비교 중심
+- purpose별 분량도 **가이드라인** (절대값 아닌 참고)
+- Presenton 연구의 min/maxLength처럼 **생성 단계에서 overflow를 예방**하는 원칙
+
+---
+
+## 예상 효과
+
+| 문제 | K 적용 후 |
+|------|----------|
+| 비교표 화면 밖 잘림 | 문제제기 간결(compact) → 비교표에 공간 확보 |
+| 용어간 상호관계가 주인공 | 핵심전달이 주인공, 상호관계는 축약 또는 sidebar |
+| sidebar 과도한 강조 | 시각적 무게 낮춤 + 분량 간결 |
+| 비교표 맥락 없음 | 비교 목적 안내 선행 |
+| 콘텐츠 흐름 반복 무시 | KEI_PROMPT에 원칙 반영 + Kei 검수에서 확인 |
+
+---
+
+## 실행 방안 상세
+
+### K-Step 1: 콘텐츠 설계 — KEI_PROMPT + EDITOR_PROMPT
+
+**대상 파일:** `src/kei_client.py` KEI_PROMPT (20~70행), `src/content_editor.py` EDITOR_PROMPT (26~63행)
+
+#### K-1 + K-2: KEI_PROMPT 3단계(스토리라인 설계) 수정
+
+**현재:** purpose 목록만 나열. 비중/순서 원칙 없음.
+**변경:** purpose별 시각적 비중 원칙 + 인지 흐름 원칙 추가.
+
+```
+변경할 프롬프트 내용:
+
+## 3단계: 슬라이드 스토리라인 설계
+
+핵심 메시지를 전달하기 위한 흐름을 설계해줘.
+
+### purpose별 시각적 비중 원칙
+- 핵심전달: body의 **주인공**. 가장 큰 공간(40-60%). 구조화된 블록으로.
+- 문제제기: **도입부**. 간결하게(compact). 2-3줄이면 충분.
+- 근거사례: **보조**. 핵심만 짧게. 상세는 sidebar 참조 또는 자세히보기.
+- 용어정의: **sidebar 참조**. body에 넣지 마라. 각 용어 1-2줄.
+- 결론강조: **footer 1줄**. core_message를 짧고 강하게.
+
+### 인지 흐름 원칙
+- 핵심전달이 body의 중심에 오도록 설계하라.
+- 문제제기와 근거사례는 핵심전달을 위한 도입부이다.
+- 콘텐츠 유형에 따라 순서를 판단하되,
+ 핵심전달이 항상 가장 큰 시각적 비중을 가져야 한다.
+```
+
+**충돌:** 없음. KEI_PROMPT 3단계 섹션 교체. 기존 purpose 목록은 위 내용으로 대체.
+**회귀:** Phase J에서 수정한 KEI_PROMPT를 다시 수정. 방향이 같으므로 회귀 아님.
+**하드코딩:** 아님. 순서 강제가 아닌 원칙 제공. Kei가 콘텐츠에 맞게 판단.
+
+#### K-8: KEI_PROMPT에 비교 맥락 원칙 추가
+
+**변경:** 배치 규칙 섹션에 1줄 추가.
+
+```
+- 핵심전달이 비교 구조일 때, 비교 목적(왜 비교하는가)을 summary에 명시하라.
+```
+
+#### K-4: EDITOR_PROMPT에 purpose별 분량 가이드 추가
+
+**현재:** 분량 제약 없음. "글자 수 가이드는 참고"만.
+**변경:** purpose별 분량 원칙 추가.
+
+```
+## purpose별 분량 원칙 (가이드라인)
+- 문제제기: max 100자 (2-3줄). 간결하게. 도입부.
+- 근거사례: max 150자. 핵심만 짧게. 상세는 자세히보기.
+- 핵심전달: 200-400자. 충분히 구조화. 이것이 주인공.
+- 용어정의: 각 용어 max 50자. sidebar에서 짧게 정의.
+- 결론강조: max 40자. 기억할 1문장.
+```
+
+**충돌:** 없음. EDITOR_PROMPT에 섹션 추가만.
+**회귀:** Phase J의 source 규칙(J-4)은 유지됨.
+
+---
+
+### K-Step 2: 블록 선택 — STEP_B_PROMPT + catalog
+
+**대상 파일:** `src/design_director.py` STEP_B_PROMPT (501~508행), `templates/catalog.yaml`
+
+#### K-3: STEP_B_PROMPT purpose 가이드를 허용/금지로 재구성
+
+**현재:** (Phase J에서 수정한 상태)
+```
+- 문제제기 → callout-warning, quote-big-mark, quote-question
+- 근거사례 → quote-big-mark (출처 포함), card-numbered (항목 나열)
+- 핵심전달 → comparison-2col, compare-pill-pair, compare-2col-split
+- 용어정의 → card-numbered (정의 나열), dark-bullet-list (핵심 포인트)
+- 결론강조 → banner-gradient (footer)
+- 구조시각화 → venn-diagram (단독 배치)
+```
+
+**변경:**
+```
+## purpose별 블록 선택 규칙
+
+### 문제제기 (간결한 도입부)
+- 허용: callout-warning, quote-big-mark, quote-question
+- 금지: 비교 블록, 카드 블록, 시각화 블록
+- 크기: compact (70px 이하)
+
+### 근거사례 (보조 증빙)
+- 허용: card-numbered, dark-bullet-list, card-tag-image(sidebar)
+- 금지: 비교표 (핵심전달과 혼동), quote 계열
+- 크기: compact~medium
+
+### 핵심전달 (★ 주인공 — body에서 가장 크게)
+- 허용: compare-2col-split, comparison-2col, compare-3col-badge, topic-left-right
+- 금지: card-icon-desc, quote 계열 (주인공에 부적합)
+- 크기: large 권장
+
+### 용어정의 (sidebar 전용)
+- 허용: card-numbered, dark-bullet-list
+- 금지: 비교 블록, 시각화 블록, card-icon-desc
+- 배치: 반드시 sidebar. body에 넣지 마라.
+
+### 결론강조 (footer 1줄)
+- 허용: banner-gradient
+- 배치: 반드시 footer.
+```
+
+**충돌:** Phase J의 J-3a 수정을 대체. 방향 동일(card-icon-desc 제거), 더 구체화.
+**회귀:** J-3a보다 상세해진 것이므로 회귀 아님.
+
+#### K-6: STEP_B_PROMPT에 sidebar 원칙 추가
+
+**변경:** 블록 선택 규칙 섹션에 추가.
+
+```
+- sidebar 블록은 본문보다 시각적 무게가 낮아야 한다.
+- sidebar에는 compact 블록 우선. large 블록 금지.
+- sidebar의 카드는 1열 세로 배치. 3열 가로 금지.
+```
+
+---
+
+### K-Step 3: 코드 + 검수
+
+**대상 파일:** `src/content_editor.py` fill_content(), `src/kei_client.py` KEI_REVIEW_PROMPT
+
+#### K-5: column_override 보존
+
+**현재:** `orig_block["data"] = filled_block.get("data", {})` — 통째 덮어쓰기.
+**변경:** column_override 키를 보존하고 나머지만 덮어쓰기.
+
+```python
+new_data = filled_block.get("data", {})
+preserved = {}
+if "data" in orig_block:
+ for k in ("column_override",):
+ if k in orig_block["data"]:
+ preserved[k] = orig_block["data"][k]
+orig_block["data"] = {**new_data, **preserved}
+```
+
+**주의:** fill_content()에서 data를 덮어쓰는 곳이 2곳 (topic_id 매칭 + area+type 매칭). 둘 다 수정.
+**충돌:** 없음. 기존 data 덮어쓰기 로직에 보존 로직 추가.
+
+#### K-7: KEI_REVIEW_PROMPT 구조 흐름 검수
+
+**현재:** 높이 초과, 채움 균형, 빈 블록만 검수.
+**변경:** 검수 항목에 추가.
+
+```
+6. 핵심전달이 body에서 가장 큰 시각적 비중을 차지하는가?
+ - 핵심전달 블록이 다른 블록보다 작거나 같으면 → rewrite로 비중 조정
+7. 문제제기가 간결한가? (100자 이내)
+ - 초과 시 → shrink
+8. 용어정의가 sidebar에 있는가?
+ - body에 있으면 → 구조 문제 지적
+9. 핵심전달 블록이 화면 안에 보이는가?
+ - 잘리면 → overflow_detected
+```
+
+**충돌:** Phase J의 J-7에서 추가한 KEI_REVIEW_PROMPT에 항목 추가. 기존 항목 변경 없음.
+
+---
+
+## 실행 프로세스
+
+```
+K-Step 1 (콘텐츠 설계)
+├── K-1 + K-2: KEI_PROMPT 3단계 수정 (purpose 비중 + 인지 흐름)
+├── K-4: EDITOR_PROMPT 분량 가이드 추가
+└── K-8: KEI_PROMPT 비교 맥락 원칙 추가
+ ↓
+K-Step 2 (블록 선택)
+├── K-3: STEP_B_PROMPT purpose별 허용/금지 재구성
+└── K-6: STEP_B_PROMPT sidebar 원칙 추가
+ ↓
+K-Step 3 (코드 + 검수)
+├── K-5: content_editor.py column_override 보존
+└── K-7: KEI_REVIEW_PROMPT 구조 흐름 검수 추가
+ ↓
+검증: import + 서버 기동 + 결과물 비교
+```
+
+---
+
+## 충돌/회귀 검토
+
+| 항목 | Phase I 영향 | Phase J 영향 | 하드코딩 |
+|------|:----------:|:----------:|:------:|
+| K-1 | 없음 | 없음 | 아님 (원칙) |
+| K-2 | 없음 | 없음 | 아님 (원칙) |
+| K-3 | I-1 purpose 가이드 → K-3이 대체 | J-3a → K-3이 대체 (더 상세) | 아님 (허용/금지 분류) |
+| K-4 | 없음 | 없음 | 아님 (가이드라인) |
+| K-5 | 없음 | J-6 column_override와 연동 | 없음 |
+| K-6 | 없음 | J-6과 보완 | 아님 (원칙) |
+| K-7 | 없음 | J-7 KEI_REVIEW_PROMPT에 추가 | 없음 |
+| K-8 | 없음 | 없음 | 아님 (원칙) |
+
+---
+
+## 이력
+
+| 날짜 | 내용 |
+|------|------|
+| 2026-03-26 | Phase J 완료 후 결과물 확인. 사용자 반복 요청(콘텐츠 구조 흐름)이 미반영 확인. 연구 참고(Presenton/PPTAgent/Auto-Slides). Phase K 계획 수립. |
+| 2026-03-26 | 실행 방안 상세 정리. Step별 변경 내용 + 적용 위치 + 충돌 검토 확정. |
diff --git a/IMPROVEMENT-PHASE-K1.md b/IMPROVEMENT-PHASE-K1.md
new file mode 100644
index 0000000..708d713
--- /dev/null
+++ b/IMPROVEMENT-PHASE-K1.md
@@ -0,0 +1,276 @@
+# Phase K-1: 파이프라인 스텝별 중간 산출물 로컬 저장
+
+> 각 스텝에서 뭘 결정했고 왜 그렇게 했는지를 파일로 저장하여,
+> 사용자가 확인하고 피드백할 수 있도록 한다.
+> 당초부터 있어야 했던 기능.
+
+---
+
+## 문제
+
+- 현재 파이프라인 중간 결과는 메모리에만 존재, 파이프라인 끝나면 사라짐
+- 사용자가 "어디서 잘못됐는지" 확인할 방법이 없음
+- 로그에 WARNING/INFO 한 줄만 남아서 판단 근거 부족
+
+---
+
+## 저장 구조
+
+```
+data/runs/{timestamp}/
+├── step1_analysis.json # Kei 꼭지 추출 (topics, purpose, core_message)
+├── step1b_concepts.json # Kei 컨셉 구체화 (relation_type, expression_hint)
+├── step2_opus_recommendation.json # Opus 블록 추천
+├── step2_sonnet_mapping.json # Sonnet 최종 블록 매핑
+├── step2_validation.json # 높이 검증, 금지 블록 삭제, overflow 내역
+├── step3_filled_blocks.json # 편집자가 채운 텍스트 (블록별 data + 글자 수)
+├── step4_css_adjustment.json # CSS 변수 override 내역
+├── step4_rendered.html # 렌더링된 HTML
+├── step5_review_round1.json # Kei 1차 검수 결과 (issues + adjustments)
+├── step5_review_round2.json # Kei 2차 검수 결과 (있으면)
+└── final.html # 최종 HTML
+```
+
+---
+
+## 각 파일 내용 상세
+
+### step1_analysis.json
+```json
+{
+ "title": "건설산업 DX의 올바른 이해",
+ "core_message": "BIM은 DX의 기초적 일부분이다",
+ "total_pages": 1,
+ "info_structure": "...",
+ "topics": [
+ {
+ "id": 1,
+ "title": "DX와 BIM의 개념 혼용 현실",
+ "purpose": "문제제기",
+ "layer": "intro",
+ "role": "flow",
+ "emphasis": true,
+ "summary": "...",
+ "source_hint": "..."
+ }
+ ],
+ "images": [],
+ "tables": []
+}
+```
+
+### step1b_concepts.json
+```json
+{
+ "concepts": [
+ {
+ "topic_id": 1,
+ "relation_type": "cause_effect",
+ "expression_hint": "현상-문제 인과관계",
+ "source_data": "용어 혼용 현상..."
+ }
+ ]
+}
+```
+
+### step2_opus_recommendation.json
+```json
+{
+ "recommendations": [
+ {
+ "topic_id": 1,
+ "block_type": "quote-big-mark",
+ "area": "body",
+ "reason": "문제 제기를 임팩트 있게 강조"
+ }
+ ]
+}
+```
+
+### step2_sonnet_mapping.json
+```json
+{
+ "preset": "sidebar-right",
+ "blocks": [
+ {
+ "area": "body",
+ "type": "quote-big-mark",
+ "topic_id": 1,
+ "purpose": "문제제기",
+ "reason": "Opus 추천 유지",
+ "size": "medium",
+ "char_guide": {"quote_text": 150}
+ }
+ ],
+ "opus_diff": [
+ "Opus 추천과 동일" 또는 "topic_id 4: card-tag-image → card-numbered (사유: ...)"
+ ]
+}
+```
+
+### step2_validation.json
+```json
+{
+ "forbidden_blocks_removed": ["section-header-bar (body)"],
+ "pill_pair_replaced": [],
+ "sidebar_column_override": [{"topic_id": 4, "column_override": 1}],
+ "overflow": [
+ {"area": "body", "total_px": 510, "budget_px": 490, "overflow_px": 20}
+ ]
+}
+```
+
+### step3_filled_blocks.json
+```json
+{
+ "blocks": [
+ {
+ "area": "body",
+ "type": "quote-big-mark",
+ "topic_id": 1,
+ "purpose": "문제제기",
+ "data": {"quote_text": "건설산업의 디지털 전환...", "source": ""},
+ "char_count": 95
+ }
+ ]
+}
+```
+
+### step4_css_adjustment.json
+```json
+{
+ "area_styles": {
+ "body": "--font-body: 0.85rem; --spacing-inner: 12px;",
+ "sidebar": "--font-body: 0.8rem;",
+ "footer": ""
+ }
+}
+```
+
+### step5_review_round1.json
+```json
+{
+ "needs_adjustment": true,
+ "issues": ["body zone 높이 초과 (+20px)"],
+ "adjustments": [
+ {"block_area": "body", "action": "shrink", "target_ratio": 0.8, "detail": "..."}
+ ],
+ "kei_overflow_judgment": null
+}
+```
+
+---
+
+## 구현 방안
+
+### 반영 위치
+
+`src/pipeline.py` — `generate_slide()` 함수에서 각 스텝 완료 시 저장
+
+### 유틸 함수
+
+```python
+# pipeline.py 상단에 추가
+import time
+from pathlib import Path
+
+def _save_step(run_dir: Path, filename: str, data: Any) -> None:
+ """스텝 결과를 JSON 또는 HTML로 저장한다."""
+ run_dir.mkdir(parents=True, exist_ok=True)
+ filepath = run_dir / filename
+ if filename.endswith(".html"):
+ filepath.write_text(data, encoding="utf-8")
+ else:
+ with open(filepath, "w", encoding="utf-8") as f:
+ json.dump(data, f, ensure_ascii=False, indent=2)
+ logger.info(f"[중간 산출물] {filename} 저장")
+```
+
+### 각 스텝 저장 시점
+
+```python
+async def generate_slide(content, manual_layout=None, base_path=""):
+ run_id = str(int(time.time() * 1000))
+ run_dir = Path("data/runs") / run_id
+
+ # Step 1-A
+ analysis = await classify_content(content)
+ _save_step(run_dir, "step1_analysis.json", analysis)
+
+ # Step 1-B
+ analysis = await refine_concepts(content, analysis)
+ _save_step(run_dir, "step1b_concepts.json", {
+ "concepts": [
+ {k: t.get(k) for k in ("id", "relation_type", "expression_hint", "source_data")}
+ for t in analysis.get("topics", []) if t.get("relation_type")
+ ]
+ })
+
+ # Step 2 (Opus + Sonnet + validation)
+ layout_concept = await create_layout_concept(content, analysis)
+ _save_step(run_dir, "step2_sonnet_mapping.json", layout_concept)
+
+ # Step 3
+ layout_concept = await fill_content(content, layout_concept, analysis)
+ _save_step(run_dir, "step3_filled_blocks.json", {
+ "blocks": [
+ {
+ "area": b.get("area"),
+ "type": b.get("type"),
+ "topic_id": b.get("topic_id"),
+ "purpose": b.get("purpose"),
+ "data": b.get("data", {}),
+ "char_count": len(json.dumps(b.get("data", {}), ensure_ascii=False)),
+ }
+ for p in layout_concept.get("pages", [])
+ for b in p.get("blocks", [])
+ ]
+ })
+
+ # Step 4
+ html = render_slide(layout_concept)
+ _save_step(run_dir, "step4_rendered.html", html)
+
+ # Step 5 (검수 결과는 루프 안에서)
+ # review_result 저장
+
+ # 최종
+ _save_step(run_dir, "final.html", html)
+```
+
+### Opus 추천 저장
+
+현재 Opus 추천 결과가 `create_layout_concept()` 내부에서 소비되고 사라짐.
+추천 결과를 반환값에 포함하거나, 별도로 저장하는 로직 필요.
+
+**방법:** `create_layout_concept()` 반환값에 `"opus_recommendation"` 키 추가
+
+---
+
+## 충돌/회귀 검토
+
+| 항목 | 영향 |
+|------|------|
+| pipeline.py | `_save_step()` 함수 추가 + 각 스텝 후 호출 |
+| design_director.py | `create_layout_concept()` 반환값에 opus 추천 포함 (선택적) |
+| 기존 기능 | 변경 없음 — 저장은 추가 기능이므로 기존 흐름에 영향 없음 |
+| Phase I/J/K | 회귀 없음 |
+| 성능 | JSON 저장은 ms 수준, HTML 저장도 ms 수준 — 영향 미미 |
+
+---
+
+## 실행 순서
+
+1. `_save_step()` 유틸 함수 추가 (pipeline.py)
+2. `data/runs/` 디렉토리 구조 설정
+3. `generate_slide()` 각 스텝 완료 시점에 저장 호출 추가
+4. Opus 추천 결과 반환값 포함 (design_director.py, 선택적)
+5. 검증: 파이프라인 실행 후 `data/runs/{timestamp}/` 파일 확인
+
+---
+
+## 이력
+
+| 날짜 | 내용 |
+|------|------|
+| 2026-03-26 | Phase K 완료 후. 사용자 피드백 확인을 위한 중간 산출물 저장 기능 계획. |
diff --git a/IMPROVEMENT-PHASE-L.md b/IMPROVEMENT-PHASE-L.md
new file mode 100644
index 0000000..0f94671
--- /dev/null
+++ b/IMPROVEMENT-PHASE-L.md
@@ -0,0 +1,618 @@
+# Phase L: 렌더링 측정 에이전트 + Purpose 기반 공간 할당 + 수학적 조정
+
+> 상태: ✅ 완료 — Selenium 측정 + 피드백 루프 구축. Phase O에서 container div 감지 추가.
+>
+> Phase I~K에서 프롬프트/규칙/검수를 개선했지만, **실제 렌더링 결과를 측정하지 않아** 미충족 7건 + 부분충족 4건이 해결되지 않음.
+> **핵심: LLM이 추정하는 것이 아니라, 코드가 정확하게 계산하고 측정하는 구조로 전환.**
+>
+> **후속 변경 (Phase O):**
+> - `allocate_height_budget()` → `calculate_container_specs()`로 교체
+> - `_max_height_px` → `_container_height_px`로 교체
+> - max-height CSS 래퍼 → Phase N에서 제거
+> - `_MEASURE_SCRIPT`에 `.container-*` 셀렉터 추가
+
+---
+
+## 근본 문제
+
+현재 파이프라인은 **"만들고 나서 맞는지 모른다"** 구조.
+
+| 시점 | 지금 | 있어야 하는 것 |
+|------|------|-------------|
+| 만들기 전 | 블록 타입별 고정값 합산 (compact=70px) | purpose별 비율로 실제 px 예산 할당 |
+| 만든 후 | LLM이 HTML 텍스트 읽고 추정 | 렌더링 엔진이 실제 px 측정 |
+| 안 맞을 때 | LLM이 "shrink 0.7" 추정 | 수학 공식으로 정확한 축약량 계산 |
+
+---
+
+## 미충족 + 부분충족 전체 목록 (11건)
+
+### 미충족 7건
+
+| # | 항목 | 현재 상태 | 원인 |
+|---|------|---------|------|
+| 1 | 2단계 높이 검증 | 블록 타입별 고정값 합산 | 실제 텍스트 양 반영 안 됨 |
+| 2 | 5단계 높이 초과 감지 | 글자 수로 추정 | 실제 px 모름 |
+| 3 | 5단계 핵심전달 주인공 확인 | 추정 | 실제 크기 비율 모름 |
+| 4 | 5단계 문제제기 간결 확인 | 추정 | 실제 렌더링 높이 모름 |
+| 5 | 5단계 비교표 잘림 감지 | 추정 | scrollHeight vs clientHeight 안 봄 |
+| 6 | 4단계 CSS 조정 효과 검증 | 없음 | 조정 전후 비교 안 함 |
+| 7 | 5단계 Kei 검수 근거 | 추정 기반 | 실제 수치 없이 검수 |
+
+### 부분충족 4건
+
+| # | 항목 | 현재 상태 | 원인 |
+|---|------|---------|------|
+| 8 | Step B Sonnet 높이 예산 준수 | 프롬프트 지시만 | 물리적 강제 없음 |
+| 9 | Step 3 편집자 분량 준수 | 가이드라인만 | 정확한 max 글자 수 계산 안 됨 |
+| 10 | Step 5 shrink/expand 효과 | 비율로 조정 | 조정 후 재측정 안 함 |
+| 11 | 5단계 용어정의 sidebar 확인 | 프롬프트 지시만 | 코드 레벨 강제 없음 |
+
+---
+
+## 해결 방법 4가지
+
+### 방법 1: Purpose 기반 공간 할당 (만들기 전)
+
+**원리:** purpose의 중요도에 따라 zone 내 각 블록의 max-height를 **코드로 결정론적으로** 할당.
+
+```
+body zone = 490px (전체 예산)
+
+purpose별 비율 할당:
+ 핵심전달 = 55% → max 270px
+ 문제제기 = 20% → max 98px
+ 근거사례 = 25% → max 122px
+
+→ 블록 수와 purpose에 따라 자동 계산
+→ AI 추정이 아닌 코드 계산
+```
+
+**구현:**
+```python
+PURPOSE_WEIGHT = {
+ "핵심전달": 0.55, # 주인공 — 가장 큰 비중
+ "문제제기": 0.20, # 도입부 — 간결
+ "근거사례": 0.25, # 보조 — 짧게
+ "결론강조": 1.0, # footer 전용 (별도 zone)
+ "용어정의": 1.0, # sidebar 전용 (별도 zone)
+}
+
+def allocate_height_budget(blocks: list[dict], zone_budget_px: int) -> dict:
+ """purpose별 비중으로 각 블록의 max-height를 할당한다."""
+ flow_blocks = [b for b in blocks if b.get("role") != "reference"]
+ total_weight = sum(PURPOSE_WEIGHT.get(b.get("purpose", ""), 0.2) for b in flow_blocks)
+ gap_total = 20 * max(0, len(flow_blocks) - 1)
+ available = zone_budget_px - gap_total
+
+ allocation = {}
+ for block in flow_blocks:
+ weight = PURPOSE_WEIGHT.get(block.get("purpose", ""), 0.2)
+ ratio = weight / total_weight
+ allocation[block.get("topic_id")] = int(available * ratio)
+
+ return allocation
+ # 예: {1: 98, 3: 270, 5: 122} (topic_id → max_height_px)
+```
+
+**해결하는 미충족:** #1 (높이 검증), #3 (주인공 확인), #8 (예산 강제)
+
+---
+
+### 방법 2: 렌더링 측정 에이전트 (만든 후)
+
+**원리:** HTML을 실제 브라우저에서 렌더링하고 각 zone/block의 px을 정확히 측정.
+
+**Selenium (이미 설치됨) 사용:**
+```python
+from selenium import webdriver
+from selenium.webdriver.chrome.options import Options
+
+def measure_rendered_heights(html: str, slide_width: int, slide_height: int) -> dict:
+ """렌더링된 HTML의 각 zone/block 실제 px 높이를 측정한다."""
+ options = Options()
+ options.add_argument("--headless=new")
+ options.add_argument(f"--window-size={slide_width},{slide_height}")
+ driver = webdriver.Chrome(options=options)
+
+ try:
+ driver.get("data:text/html;charset=utf-8," + html)
+
+ results = driver.execute_script("""
+ const slide = document.querySelector('.slide');
+ const zones = {};
+
+ // 각 zone (area) 측정
+ slide.querySelectorAll('[class^="area-"]').forEach(zone => {
+ const className = zone.className;
+ const blocks = [];
+
+ zone.querySelectorAll('[class^="block-"]').forEach(block => {
+ blocks.push({
+ className: block.className,
+ scrollHeight: block.scrollHeight,
+ clientHeight: block.clientHeight,
+ overflowed: block.scrollHeight > block.clientHeight,
+ excess_px: Math.max(0, block.scrollHeight - block.clientHeight)
+ });
+ });
+
+ zones[className] = {
+ scrollHeight: zone.scrollHeight,
+ clientHeight: zone.clientHeight,
+ overflowed: zone.scrollHeight > zone.clientHeight,
+ excess_px: Math.max(0, zone.scrollHeight - zone.clientHeight),
+ blocks: blocks
+ };
+ });
+
+ // 슬라이드 전체
+ return {
+ slide: {
+ scrollHeight: slide.scrollHeight,
+ clientHeight: slide.clientHeight,
+ overflowed: slide.scrollHeight > slide.clientHeight
+ },
+ zones: zones
+ };
+ """)
+ return results
+ finally:
+ driver.quit()
+```
+
+**측정 결과 예시:**
+```json
+{
+ "slide": {"scrollHeight": 750, "clientHeight": 720, "overflowed": true},
+ "zones": {
+ "area-body": {
+ "scrollHeight": 520, "clientHeight": 490, "overflowed": true, "excess_px": 30,
+ "blocks": [
+ {"className": "block-quote-big", "scrollHeight": 160, "clientHeight": 160, "overflowed": false},
+ {"className": "block-topic-header", "scrollHeight": 80, "clientHeight": 80, "overflowed": false},
+ {"className": "block-split-compare", "scrollHeight": 280, "clientHeight": 250, "overflowed": true, "excess_px": 30}
+ ]
+ },
+ "area-sidebar": {
+ "scrollHeight": 400, "clientHeight": 490, "overflowed": false
+ }
+ }
+}
+```
+
+**viewport 크기는 config에서 읽음 (하드코딩 아님):**
+```python
+from src.config import settings
+results = measure_rendered_heights(html, settings.slide_width, settings.slide_height)
+```
+
+**해결하는 미충족:** #2 (높이 초과 감지), #5 (비교표 잘림), #6 (CSS 효과 검증), #7 (검수 근거), #10 (조정 효과)
+
+---
+
+### 방법 3: CSS max-height 제약 (구조적 보장)
+
+**원리:** 방법 1에서 할당한 max-height를 실제 CSS에 적용하여 물리적으로 넘치지 않게 함.
+
+**렌더링 시 적용:**
+```python
+# renderer.py에서 블록 렌더링 시 max-height 주입
+for block in blocks:
+ allocated = height_allocation.get(block.get("topic_id"))
+ if allocated:
+ block["_max_height_px"] = allocated
+```
+
+```html
+
+
+
+
+```
+
+**측정 에이전트(방법 2)가 overflow 감지:**
+- `scrollHeight > clientHeight` → 콘텐츠가 잘림 → 축약 필요
+- 정확한 초과량(excess_px) 제공
+
+**해결하는 미충족:** #8 (예산 강제), #11 (sidebar 물리적 강제)
+
+---
+
+### 방법 4: 조정량 수학적 계산 (AI 추정 → 공식)
+
+**원리:** 측정 에이전트가 보고한 excess_px에서 삭제할 글자 수를 수학 공식으로 계산.
+
+```python
+def calculate_trim_chars(
+ excess_px: int,
+ font_size_px: float,
+ line_height: float,
+ container_width_px: int,
+ avg_char_width_px: float = 16.0, # 한글 Pretendard 기준
+) -> int:
+ """초과 px에서 삭제할 글자 수를 수학적으로 계산한다.
+
+ AI 추정이 아닌 결정론적 공식.
+ """
+ line_height_px = font_size_px * line_height
+ lines_to_remove = math.ceil(excess_px / line_height_px)
+ chars_per_line = int(container_width_px / avg_char_width_px)
+ chars_to_remove = lines_to_remove * chars_per_line
+ return chars_to_remove
+
+# 예: excess_px=62, font=16px, line-height=1.7, width=700px
+# → line_height_px = 27.2
+# → lines_to_remove = ceil(62/27.2) = 3
+# → chars_per_line = 700/16 = 43
+# → chars_to_remove = 3 × 43 = 129자
+```
+
+**편집자 재호출 시:**
+```python
+# 기존: "shrink target_ratio: 0.7" (AI 추정)
+# 변경: "quote-big-mark의 quote_text를 129자 줄여라" (수학적 계산)
+```
+
+**해결하는 미충족:** #4 (간결 확인), #9 (편집자 분량 정확), #10 (shrink 효과)
+
+---
+
+## 전체 통합 파이프라인 (Phase L 적용 후)
+
+```
+[1단계] Kei 분석
+ → purpose별 꼭지 + 비중 결정
+ ↓
+[방법 1] Purpose 기반 공간 할당 (코드, 결정론적)
+ → body 내 각 블록별 max-height 할당 (px)
+ → max 글자 수 수학적 계산 (방법 4)
+ ↓
+[2단계] 팀장 블록 선택
+ → 할당된 max-height 안에서 가능한 블록만 선택
+ ↓
+[3단계] 편집자 텍스트 채움
+ → max 글자 수 제약 (수학적 계산 기반, AI 추정 아님)
+ ↓
+[4단계] CSS 조정 + 렌더링
+ → max-height CSS 제약 포함 (방법 3)
+ ↓
+[방법 2] 렌더링 측정 에이전트 (Selenium)
+ → 각 zone/block의 실제 px 측정
+ → overflow 감지 (scrollHeight > clientHeight)
+ ↓
+ ├── 맞으면 → [5단계] Kei 검수 (실제 px 수치 전달)
+ │ Kei가 받는 정보:
+ │ "body zone: 실제 480px / 예산 490px — OK"
+ │ "핵심전달 블록: 260px (body의 54%) — 주인공 비중 충족"
+ │ "비교표: 250px, 잘림 없음"
+ │ → 근거 있는 콘텐츠 검수 가능
+ │
+ └── 안 맞으면 → [방법 4] 수학적 축약량 계산
+ "quote-big-mark: 62px 초과 → 129자 삭제 필요"
+ → 편집자 재호출 (정확한 글자 수)
+ → 재렌더링 → 재측정 → 반복
+```
+
+---
+
+## 미충족/부분충족 해결 매핑
+
+| # | 항목 | 해결 방법 | 근거 |
+|---|------|----------|------|
+| 1 | 2단계 높이 검증이 추정 | 방법 1 (할당) + 방법 2 (측정) | purpose별 px 할당 + 실제 렌더링 검증 |
+| 2 | 5단계 높이 초과 감지가 추정 | 방법 2 (측정) | scrollHeight > clientHeight 정확 감지 |
+| 3 | 5단계 핵심전달 주인공 확인 불가 | 방법 1 (할당) + 방법 2 (측정) | 할당 비율 55% 대비 실제 비율 비교 |
+| 4 | 5단계 문제제기 간결 확인 불가 | 방법 2 (측정) + 방법 4 (계산) | 실제 px + 수학적 글자 수 계산 |
+| 5 | 5단계 비교표 잘림 감지 불가 | 방법 2 (측정) | scrollHeight > clientHeight로 잘림 정확 감지 |
+| 6 | 4단계 CSS 조정 효과 검증 불가 | 방법 2 (측정) | 조정 전후 실제 px 비교 |
+| 7 | 5단계 Kei 검수 근거 없음 | 방법 2 (측정) | 실제 px 수치를 Kei에게 전달 |
+| 8 | Step B 높이 예산 안 지킴 | 방법 1 (할당) + 방법 3 (CSS) | max-height로 물리적 강제 |
+| 9 | 편집자 분량 안 지킴 | 방법 4 (계산) | 할당 높이에서 max 글자 수 수학적 계산 |
+| 10 | shrink 효과 검증 불가 | 방법 2 (측정) | 조정 후 재렌더링 → 재측정 |
+| 11 | 용어정의 sidebar 강제 | 방법 3 (CSS) | sidebar 외 zone에서 용어정의 블록 물리적 차단 |
+
+---
+
+## 실행 순서
+
+### L-Step 1: 공간 할당 엔진
+
+1. `PURPOSE_WEIGHT` 상수 + `allocate_height_budget()` 함수
+2. `calculate_trim_chars()` 수학적 글자 수 계산 함수
+3. pipeline.py에서 2단계 완료 후 할당 실행
+
+### L-Step 2: 렌더링 측정 에이전트
+
+4. `measure_rendered_heights()` 함수 (Selenium headless)
+5. pipeline.py에서 4단계 완료 후 측정 실행
+6. 측정 결과를 step4_measurement.json으로 저장 (K-1 연동)
+
+### L-Step 3: CSS max-height 제약
+
+7. renderer.py에서 블록별 max-height 적용
+8. 할당 → CSS 제약 → 렌더링 → 측정 파이프 연결
+
+### L-Step 4: 피드백 루프
+
+9. 측정 결과 overflow → 수학적 축약량 계산 → 편집자 재호출
+10. 재렌더링 → 재측정 → 맞으면 5단계로
+11. Kei 검수에 실제 px 수치 전달
+
+---
+
+## 필요 기술/도구
+
+| 도구 | 용도 | 설치 상태 |
+|------|------|----------|
+| Selenium + Chrome headless | 렌더링 측정 | **설치됨** (4.34.0) |
+| ChromeDriver | Selenium 구동 | webdriver-manager로 자동 관리 |
+| math (Python 표준) | 축약량 계산 | 기본 포함 |
+| config.py settings | viewport 크기 (하드코딩 방지) | 이미 존재 (slide_width, slide_height) |
+
+---
+
+## 하드코딩 방지
+
+- viewport 크기: `settings.slide_width`, `settings.slide_height`에서 읽음
+- purpose 비율: `PURPOSE_WEIGHT` 상수 (범용, 콘텐츠 무관)
+- 글자 수 계산: 폰트 크기/line-height를 CSS 변수에서 읽거나 config에서 관리
+- 반응형 전환 시: config만 바꾸면 측정도 따라감
+
+---
+
+## 코드 조사 결과 (정밀 검토)
+
+### 현재 있는 것
+
+| 항목 | 위치 | 상태 |
+|------|------|------|
+| zone별 budget_px | design_director.py 322~370행 | 4개 프리셋 × 4개 zone |
+| HEIGHT_COST_PX | design_director.py 906~911행 | compact=70, medium=150, large=250, xlarge=400 |
+| overflow 수집 함수 | design_director.py 962~1069행 | 블록 타입 기반 추정 (실제 렌더링 아님) |
+| style_override 주입 경로 | slide-base.html 45행 | max-height 주입 가능 |
+| Selenium | v4.34.0 | 사용 가능 |
+| Pillow | 설치됨 | 사용 가능 |
+| config slide_width/height | config.py | 1280/720 |
+
+### 없는 것 (Phase L에서 구현)
+
+| 항목 | 필요 이유 |
+|------|----------|
+| PURPOSE_WEIGHT 상수 | purpose → 공간 비율 매핑. 현재 존재하지 않음 |
+| allocate_height_budget() | zone 내 블록별 max-height 계산. 현재 없음 |
+| measure_rendered_heights() | 실제 렌더링 px 측정. 현재 없음 |
+| calculate_trim_chars() | 초과 px → 삭제 글자 수 계산. 현재 없음 |
+| Pretendard 로컬 폰트 | CDN만 있음. Pillow 계산용으로 다운로드 필요 |
+| max-height CSS 적용 | 현재 area에 max-height 없음 |
+
+---
+
+## 충돌/회귀 검토
+
+### 방법 1 (Purpose 할당)
+
+- `PURPOSE_WEIGHT` 상수 신규 추가 → 기존 코드와 **충돌 없음**
+- `allocate_height_budget()` 신규 함수 → `_validate_height_budget()`와 **별개**, 충돌 없음
+- pipeline.py Stage 2 이후 삽입 → 기존 흐름 **변경 없이 추가**
+- Phase I~K 회귀 없음
+
+### 방법 2 (Selenium 측정)
+
+- `measure_rendered_heights()` 신규 모듈 (`src/slide_measurer.py`) → 기존 코드와 **충돌 없음**
+- pipeline.py Stage 4 이후 삽입 → 기존 `render_slide()` 결과를 입력으로 사용
+- **주의:** Selenium 동기식 → `asyncio.to_thread()` 래핑 필요
+- Kei 검수에 측정 결과 전달 → `call_kei_final_review()` 파라미터 확장
+- **회귀 없음:** 기존 HTML 렌더링 그대로, 측정은 추가 단계
+
+### 방법 3 (CSS max-height)
+
+- style_override에 max-height 주입 → 기존 `area_styles` 구조 활용
+- **충돌 주의:** Phase A-5에서 `.slide > div { overflow: visible }`로 변경한 이유가 "텍스트 잘림 방지"
+ - max-height 적용 시 overflow: visible과 충돌
+ - **해결:** 측정 시에만 overflow: hidden 임시 적용하거나, 블록 레벨에서만 max-height 적용 (area 레벨이 아닌)
+- Phase I~K 회귀 없음
+
+### 방법 4 (수학적 계산)
+
+- Pretendard 로컬 폰트 필요 → CDN에서 다운로드하여 `data/fonts/`에 캐싱
+- Pillow `multiline_textbbox()` 사용 → 기존 코드와 **충돌 없음**
+- `calculate_trim_chars()` 신규 유틸 → 별도 모듈
+- Phase I~K 회귀 없음
+
+---
+
+## Kei vs Sonnet vs 코드 역할 분담
+
+| 역할 | 담당 | AI/코드 |
+|------|------|---------|
+| Purpose 비율 결정 | **코드** (PURPOSE_WEIGHT) | 결정론적 |
+| max-height 할당 | **코드** (allocate_height_budget) | 결정론적 |
+| max 글자 수 계산 | **코드** (calculate_trim_chars) | 결정론적 |
+| 렌더링 측정 | **Selenium** (브라우저 엔진) | 결정론적 |
+| overflow 감지 | **코드** (scrollHeight > clientHeight) | 결정론적 |
+| 텍스트 축약 실행 | **Kei** (편집자, Kei API) | AI (도메인 지식) |
+| 최종 검수 | **Kei** (실장, Kei API) | AI (실제 px 수치 기반) |
+| CSS 조정 | **Sonnet** (실무자) | AI (Stage 4 기존) |
+
+**핵심:** 측정/계산/감지는 전부 **코드(결정론적)**. AI는 콘텐츠 판단(축약/검수)만.
+
+---
+
+## 주의가 필요한 3곳
+
+### 1. overflow: visible vs max-height 충돌
+
+**현재:** `.slide > div { overflow: visible }` (Phase A-5)
+**Phase L:** 블록에 max-height 적용 시 넘치는 콘텐츠가 visible 상태로 보임
+**해결 방안:**
+- (A) 블록 wrapper에 `overflow: hidden` + max-height → 블록 레벨에서 잘림
+- (B) area 레벨은 visible 유지, 블록 레벨에서만 제약 → Phase A-5 원칙 유지
+- **권장: (B)** — area는 건드리지 않고, 개별 블록 wrapper에만 max-height 적용
+
+### 2. Selenium 동기식 → async 파이프라인
+
+**현재:** pipeline.py 전체가 async
+**Selenium:** 동기식 API
+**해결:**
+```python
+import asyncio
+
+async def measure_async(html: str) -> dict:
+ return await asyncio.to_thread(measure_rendered_heights, html)
+```
+
+### 3. Pretendard 로컬 폰트
+
+**현재:** CDN만 (@import url)
+**Pillow 계산에 필요:** 로컬 .ttf 파일
+**해결:**
+- 첫 실행 시 CDN에서 다운로드 → `data/fonts/Pretendard-Regular.ttf` 캐싱
+- 또는 프로젝트에 폰트 파일 포함 (라이선스: OFL — 재배포 가능)
+
+---
+
+## 실행 방안 상세
+
+### L-Step 1: 공간 할당 엔진
+
+**신규 파일:** `src/space_allocator.py`
+
+```python
+PURPOSE_WEIGHT = {
+ "핵심전달": 0.55,
+ "문제제기": 0.20,
+ "근거사례": 0.25,
+ "결론강조": 1.0, # footer 전용
+ "용어정의": 1.0, # sidebar 전용
+}
+
+def allocate_height_budget(blocks, zone_budget_px, gap_px=20):
+ """purpose 비중으로 각 블록의 max-height를 할당한다. 결정론적."""
+ ...
+
+def calculate_max_chars(max_height_px, font_size_px, line_height, container_width_px, font_path):
+ """할당된 높이에서 최대 글자 수를 수학적으로 계산한다."""
+ ...
+
+def calculate_trim_chars(excess_px, font_size_px, line_height, container_width_px, font_path):
+ """초과 px에서 삭제할 글자 수를 수학적으로 계산한다."""
+ ...
+```
+
+**반영 위치:** pipeline.py Stage 2 완료 후
+**충돌:** 없음. 신규 모듈.
+**회귀:** 없음.
+
+### L-Step 2: 렌더링 측정 에이전트
+
+**신규 파일:** `src/slide_measurer.py`
+
+```python
+from selenium import webdriver
+from selenium.webdriver.chrome.options import Options
+from src.config import settings
+
+def measure_rendered_heights(html: str) -> dict:
+ """렌더링된 HTML의 각 zone/block 실제 px을 측정한다. 결정론적."""
+ options = Options()
+ options.add_argument("--headless=new")
+ options.add_argument(f"--window-size={settings.slide_width},{settings.slide_height}")
+ driver = webdriver.Chrome(options=options)
+ try:
+ driver.get("data:text/html;charset=utf-8," + html)
+ # 폰트 로딩 대기
+ driver.execute_script("return document.fonts.ready")
+ # 각 zone/block 측정
+ results = driver.execute_script("""...""")
+ return results
+ finally:
+ driver.quit()
+```
+
+**반영 위치:** pipeline.py Stage 4 완료 후 (렌더링 직후)
+**저장:** `step4_measurement.json` (K-1 연동)
+**충돌:** 없음. 신규 모듈.
+**회귀:** 없음.
+
+### L-Step 3: CSS max-height 제약
+
+**반영 위치:** renderer.py 블록 렌더링 시
+**방식:** 블록 wrapper에 max-height 적용 (area 레벨 아님 — Phase A-5 원칙 유지)
+
+```html
+
+
+ {{ block_html }}
+
+```
+
+**충돌:** Phase A-5 overflow: visible은 area 레벨 → 블록 레벨 max-height와 충돌 없음
+**회귀:** 없음.
+
+### L-Step 4: 피드백 루프
+
+**반영 위치:** pipeline.py Stage 4~5 사이
+
+```
+렌더링 완료 (Stage 4)
+ ↓
+측정 (slide_measurer)
+ ↓
+overflow 있으면:
+ 수학적 축약량 계산 (space_allocator)
+ 편집자 재호출 (fill_content) — "quote_text를 129자 줄여라"
+ 재렌더링 (render_slide)
+ 재측정
+ MAX 3회 반복
+ ↓
+overflow 없으면:
+ Kei 검수 (call_kei_final_review) — 실제 px 수치 포함
+```
+
+**Kei 검수에 전달할 측정 결과:**
+```
+"body zone: 실제 480px / 예산 490px — OK"
+"핵심전달(compare-2col-split): 260px (body의 54%) — 주인공 비중 충족"
+"문제제기(quote-big-mark): 90px (body의 19%) — 간결"
+"비교표: scrollHeight=250, clientHeight=260 — 잘림 없음"
+```
+
+**충돌:** 기존 Stage 5 Kei 검수 구조 유지. 파라미터에 measurement 추가만.
+**회귀:** 없음.
+
+---
+
+## 하드코딩 방지 확인
+
+| 항목 | 하드코딩? | 근거 |
+|------|:--------:|------|
+| PURPOSE_WEIGHT 비율 | 아님 | 범용 상수. 콘텐츠 유형 무관. |
+| max-height px | 아님 | budget_px × purpose 비율로 계산. 고정값 아님. |
+| viewport 크기 | 아님 | settings.slide_width/height에서 읽음. |
+| 폰트 메트릭 | 아님 | Pillow가 실제 폰트 파일에서 측정. |
+| 축약 글자 수 | 아님 | excess_px / line_height × chars_per_line 공식 계산. |
+| CSS max-height | 아님 | allocate_height_budget() 결과를 동적 주입. |
+| overflow 감지 | 아님 | scrollHeight > clientHeight 브라우저 네이티브. |
+
+---
+
+## 예상 효과 (Phase L 적용 전후)
+
+| 항목 | Phase L 전 | Phase L 후 |
+|------|-----------|-----------|
+| 비교표 잘림 | 모름 | **scrollHeight 250 > clientHeight 240 → 10px 잘림 감지** |
+| 핵심전달 주인공 | 추정 | **260px / 490px = 53% — 주인공 비중 수치로 확인** |
+| 문제제기 간결 | 추정 | **90px / 98px 할당 — 할당 내 OK** |
+| shrink 효과 | 모름 | **조정 전 520px → 조정 후 480px — 40px 감소 확인** |
+| Kei 검수 | 근거 없음 | **실제 px 수치 기반 판단** |
+| 편집자 분량 | 가이드만 | **max 129자 — 수학적 계산** |
+
+---
+
+## 이력
+
+| 날짜 | 내용 |
+|------|------|
+| 2026-03-26 | Phase K 완료 후 결과물 분석. 미충족 7건 + 부분충족 4건 전수 진단. 4가지 해결 방법 도출. Phase L 계획 수립. |
+| 2026-03-26 | 코드 전수 조사 + 충돌/회귀 정밀 검토 완료. 주의 사항 3곳 식별. 실행 방안 상세 확정. |
diff --git a/IMPROVEMENT-PHASE-M.md b/IMPROVEMENT-PHASE-M.md
new file mode 100644
index 0000000..86108d1
--- /dev/null
+++ b/IMPROVEMENT-PHASE-M.md
@@ -0,0 +1,605 @@
+# Phase M: 비중 시스템 + 역할-블록 매핑 + 블록 안전성 + 원본 보존
+
+> 상태: ✅ 완료 — Kei 비중 시스템 구축. Phase O에서 컨테이너 시스템으로 발전.
+>
+> Phase I~L에서 코드 정합성, 블록 선택 권한, 프롬프트 원칙, 렌더링 측정을 다뤘지만
+> **근본 문제가 해결되지 않음: "이 페이지의 본심이 뭔지" 판단이 없음.**
+> Kei가 콘텐츠마다 본심/배경/첨부/결론을 판단하고, 비중(weight)을 결정해야 함.
+> 코드 상수(하드코딩)가 아닌 **Kei의 매번 판단**.
+>
+> **후속 변경 (Phase O):**
+> - pipeline.py의 Phase M 공간 할당 코드 → Phase O `calculate_container_specs()`로 교체
+> - `PURPOSE_WEIGHT` 상수 → 삭제 (Kei weight 직접 사용)
+> - `allocate_height_budget()` → `calculate_container_specs()` + `finalize_block_specs()`로 교체
+
+---
+
+## 문제점 전체 리스트 (9건)
+
+### P-1: 비중(weight) 개념 부재
+
+**현상:** Kei가 꼭지 5개를 분류하면, 팀장이 5개를 동등하게 1:1 배치. "본심이 60%, 배경이 15%"라는 공간 비중 개념이 파이프라인 어디에도 없음.
+
+**예시:** DX vs BIM 비교(본심)와 용어 정의(첨부)가 동일한 크기의 블록을 받음.
+
+**영향:** 핵심 메시지가 묻히고, 보조 정보가 과도한 공간 차지.
+
+**위치:** 1단계(Kei) 출력 → 2단계(팀장) 입력 사이.
+
+**Phase I~L에서 한 것:** Phase K에서 PURPOSE_WEIGHT 상수 추가, Phase L에서 allocate_height_budget() 함수 추가.
+**문제:** 하드코딩된 고정 비율. 콘텐츠마다 다른데 코드가 일괄 적용.
+
+---
+
+### P-2: 편집자적 구조 판단 부재
+
+**현상:** Kei가 꼭지를 "나열"만 함. 아래와 같은 편집 구조를 잡지 못함:
+
+```
+(배경/목적) 왜 이 페이지가 필요한가
+(본심) 이 페이지가 말하려는 핵심
+(첨부) 본심을 이해하기 위한 보조 정보
+(잊지마) 절대 잊으면 안 되는 결론
+```
+
+**현재:** purpose와 layer가 있지만 "이 페이지의 본심은 꼭지2이고 나머지는 보조다"라는 판단이 없음.
+
+**영향:** 모든 꼭지가 동등하게 취급됨. 스토리라인은 있으나 강약이 없음.
+
+**위치:** 1단계 KEI_PROMPT.
+
+**Phase I~L에서 한 것:** Phase K에서 인지 흐름 원칙 추가.
+**문제:** 원칙만 줬지 Kei 출력 스키마에 "본심/배경/첨부/결론" 구분이 없음.
+
+---
+
+### P-3: 블록 선택이 "콘텐츠 역할"이 아닌 "데이터 타입"으로 결정됨
+
+**현상:** 팀장(Sonnet)이 블록을 고를 때 "텍스트 → 텍스트 블록, 표 → 표 블록"으로 데이터 형식만 보고 선택. "이것이 본심이니까 정보 밀도 높은 블록" 판단 안 함.
+
+**올바른 선택 기준:**
+```
+본심(핵심전달) → 정보형 블록 (compare-2col-split 등) → 공간 최대
+배경(문제제기) → 컴팩트 블록 (topic-left-right 등) → 공간 최소
+첨부(용어정의) → 참조형 블록 (card-numbered 등) → sidebar
+결론(강조) → 선언형 블록 (banner-gradient) → footer
+```
+
+**위치:** 2단계 STEP_B_PROMPT + FAISS 검색.
+
+**Phase I~L에서 한 것:** Phase K에서 purpose별 허용/금지 블록 규칙 추가.
+**문제:** purpose 기반이지 "본심/배경" 기반이 아님. Kei가 비중을 출력해야 팀장이 비중대로 블록 크기 결정.
+
+---
+
+### P-4: 공간 배분 로직 부재
+
+**현상:** 팀장이 zone별 height_cost만 검증하고, "이 꼭지에 몇 px를 줘야 하는가"는 계산하지 않음.
+
+**현재 로직:** 블록 선택 → height_cost 합산 확인 → 초과하면 교체
+**필요한 로직:** 비중(weight) 확인 → weight에 따라 zone 예산 배분 → 배분된 px에 맞는 블록 선택
+
+**위치:** 2단계 create_layout_concept().
+
+**Phase I~L에서 한 것:** Phase L에서 allocate_height_budget() + max-height CSS 적용.
+**문제:** PURPOSE_WEIGHT가 하드코딩. Kei가 판단한 weight를 사용해야 함.
+
+---
+
+### P-5: Figma 비추출 블록 사용
+
+**현상:** 38개 블록 중 9개가 Figma 디자인 없이 코드로 만든 블록. 디자인 품질 미검증.
+
+**비-Figma 블록 (9개):**
+- topic-numbered, card-numbered, table-simple-striped
+- venn-diagram, process-horizontal
+- comparison-2col, callout-warning
+- divider-text, image-before-after
+
+**영향:** 시각적 통일성 저하.
+
+**위치:** 2단계 블록 선택 시 필터링 없음.
+
+**Phase I~L에서 한 것:** 안 다룸.
+
+---
+
+### P-6: 블록-zone 적합성 검증 부재
+
+**현상:** sidebar(35%)에 full-width 전용 블록을 배치하면 찌그러짐. 블록이 어떤 zone에서 작동하는지 검증 없음.
+
+**full-width 전용 블록 (15개):**
+- card-icon-desc, card-compare-3col, comparison-2col
+- topic-left-right, compare-pill-pair, process-horizontal 등
+
+**영향:** sidebar에서 블록 깨짐, 텍스트 한 글자씩 줄바꿈.
+
+**위치:** 2단계 블록 선택 후 검증.
+
+**Phase I~L에서 한 것:** Phase J에서 sidebar 1열 강제(column_override). 불완전.
+
+---
+
+### P-7: 블록별 글자 수용량 미정의
+
+**현상:** 블록에 텍스트를 넣을 때 "얼마나 들어가는지" 기준 없음. char_guide 참고하지만 실제 렌더링과 괴리.
+
+**결과:** 텍스트 과다 → overflow / 텍스트 과소 → 빈 페이지.
+
+**위치:** catalog.yaml에 schema 미정의. 3단계 편집자 프롬프트.
+
+**Phase I~L에서 한 것:** Phase I에서 slot_desc 추가, Phase K에서 분량 가이드라인 추가. 실제 수용량은 미정의.
+
+---
+
+### P-8: 내부 스크롤 미감지
+
+**현상:** 5단계 검수에서 area 레벨 overflow만 체크. 블록 내부의 overflow: auto/hidden으로 인한 내부 스크롤/잘림은 감지 못함.
+
+**예시:** compare-3col-badge는 overflow: auto여서 area는 OK인데 블록 안에서 스크롤 발생.
+
+**영향:** "검증 통과"했는데 실제로는 내용 잘림.
+
+**위치:** 5단계 검수.
+
+**Phase I~L에서 한 것:** Phase L에서 Selenium 측정 추가. 하지만 블록 내부 overflow까지 체크하는지 미확인.
+
+---
+
+### P-9: 원본 텍스트 임의 재작성
+
+**현상:** 3단계 편집자가 원본을 "편집"이 아닌 "재작성". 원본 문구, 출처, 수치 변경/누락.
+
+**영향:** 정보 정확도 저하, 출처 누락.
+
+**위치:** 3단계 편집자 프롬프트 + Kei API 응답 품질.
+
+**Phase I~L에서 한 것:** Phase J에서 source 슬롯 규칙 추가, EDITOR_PROMPT에 보존 원칙. 강제력 부족.
+
+---
+
+## 개선 방향 (4가지)
+
+### 방향 1: 비중(weight) 시스템 — P-1, P-2, P-4 해결 [긴급]
+
+**핵심:** Kei가 콘텐츠마다 본심/배경/첨부/결론을 판단하고 weight를 출력.
+
+**KEI_PROMPT 출력 스키마 변경:**
+```json
+{
+ "title": "건설산업 DX의 올바른 이해",
+ "core_message": "BIM은 DX의 기초적 일부분이다",
+ "page_structure": {
+ "본심": {"topic_ids": [2, 3], "weight": 0.60},
+ "배경": {"topic_ids": [1], "weight": 0.15},
+ "첨부": {"topic_ids": [4], "weight": 0.15},
+ "결론": {"topic_ids": [5], "weight": 0.10}
+ },
+ "topics": [...]
+}
+```
+
+**파이프라인 반영:**
+- 1단계: Kei가 page_structure + weight 출력 (콘텐츠마다 다름, 하드코딩 아님)
+- 2단계: weight → px 변환 (body 490px × 0.6 = 294px → 본심)
+- 2단계: 배분된 px에 맞는 블록 선택
+- 배치: 본심 비중이 결정하면 가로/세로/구조화 방식도 자연스럽게 따라옴
+- Phase L의 PURPOSE_WEIGHT 하드코딩 제거 → Kei 출력 weight 사용
+
+---
+
+### 방향 2: 역할-블록 매핑 체계 — P-3 해결 [중요]
+
+**콘텐츠 역할 × 콘텐츠 성격 → 블록 결정:**
+
+```
+본심 + 비교 → compare-2col-split, compare-3col-badge
+본심 + 구조 → keyword-circle-row, card-step-vertical
+본심 + 정의 → card-numbered (large), dark-bullet-list (large)
+배경 + 문제 → topic-left-right (compact), quote-question (compact)
+배경 + 사례 → callout-warning (compact), quote-big-mark (compact)
+첨부 + 정의 → card-numbered (sidebar), dark-bullet-list (sidebar)
+결론 → banner-gradient (footer)
+```
+
+**반영 위치:** STEP_B_PROMPT — 현재 purpose별 허용/금지를 "역할 × 성격" 매트릭스로 확장.
+
+---
+
+### 방향 3: 블록 안전성 인프라 — P-5, P-6, P-7, P-8 해결 [중요]
+
+| 항목 | 내용 | 해결 방법 |
+|------|------|----------|
+| P-5 Figma 블록 필터 | 비-Figma 9개 블록 식별 | 블록 선택 시 Figma 블록 우선 또는 비-Figma 경고 |
+| P-6 블록-zone 적합성 | full-width 15개 블록 식별 | zone별 허용 블록 맵 (코드 검증) |
+| P-7 글자 수용량 | 블록별 max chars | catalog.yaml에 zone별 max_chars 추가 |
+| P-8 내부 스크롤 | 블록 내부 overflow 감지 | Selenium 측정 시 블록 내부까지 scrollHeight 체크 |
+
+---
+
+### 방향 4: 원본 보존 강화 — P-9 해결 [보통]
+
+**3단계 편집자에게 source_text 직접 전달:**
+- 현재: 원본 콘텐츠 전체를 주고 "여기서 가져와라"
+- 변경: 각 꼭지별로 Kei가 source_hint에 명시한 원본 텍스트를 **직접 추출하여** 편집자에게 전달
+- "이 텍스트에서 추출하라. 새로 쓰지 마라. 축약만 허용."
+
+---
+
+## 우선순위
+
+```
+[긴급] P-1 + P-2 + P-4 → 방향 1: 비중 시스템
+ ← 이것이 없으면 나머지를 해도 의미 없음
+ ← Kei가 판단. 하드코딩 아님.
+ ← 비중이 결정되면 배치, 블록 크기, 가로/세로 흐름이 자동으로 따라옴
+
+[중요] P-3 → 방향 2: 역할-블록 매핑
+ ← 비중 시스템 위에서 역할별 블록 정확 매칭
+
+[중요] P-7 + P-8 → 방향 3-a: 스키마 + 검증
+ ← 글자 수용량 정의 + 내부 overflow 감지
+
+[보통] P-5 + P-6 → 방향 3-b: 필터링
+ ← Figma 블록 우선 + zone 적합성 검증
+
+[보통] P-9 → 방향 4: 편집자 원본 보존
+```
+
+---
+
+## Phase I~L과의 관계
+
+| 기존 Phase | Phase M에서 변경 |
+|-----------|----------------|
+| Phase K PURPOSE_WEIGHT 하드코딩 | **제거** → Kei 출력 weight 사용 |
+| Phase K purpose 가이드 | **유지** + 역할×성격 매트릭스로 확장 |
+| Phase L allocate_height_budget() | **유지** + 입력을 PURPOSE_WEIGHT 대신 Kei weight로 변경 |
+| Phase L measure_rendered_heights() | **유지** + 블록 내부 overflow 체크 추가 (P-8) |
+| Phase L calculate_trim_chars() | **유지** |
+| Phase J Opus 존중 규칙 | **유지** |
+| Phase J Kei 최종 검수 | **유지** + 비중 기반 검수 항목 추가 |
+| Phase I slot_desc | **유지** |
+| Phase I SSE 공통 유틸 | **유지** |
+
+**회귀 없음.** 기존 인프라(측정, 계산, 검수) 위에 비중 시스템을 추가.
+**제거 대상:** PURPOSE_WEIGHT 하드코딩 상수만.
+
+---
+
+## 실행 순서
+
+### M-Step 1: Kei 비중 시스템 (P-1 + P-2 + P-4) [긴급]
+
+1. KEI_PROMPT 출력 스키마에 page_structure 추가
+2. Kei가 본심/배경/첨부/결론 + weight를 출력하도록 프롬프트 수정
+3. pipeline.py에서 Kei 출력의 weight를 읽어서 allocate_height_budget()에 전달
+4. PURPOSE_WEIGHT 하드코딩 제거
+5. STEP_B_PROMPT에 weight 기반 블록 크기 지시 추가
+
+### M-Step 2: 역할-블록 매핑 (P-3)
+
+6. STEP_B_PROMPT purpose 가이드를 역할×성격 매트릭스로 재구성
+7. Kei 출력의 relation_type + 역할(본심/배경/첨부)로 블록 결정
+
+### M-Step 3: 블록 안전성 (P-5 + P-6 + P-7 + P-8)
+
+8. P-5: catalog.yaml에 figma_source 필드 추가 (Figma 블록 식별)
+9. P-6: 블록-zone 적합성 맵 정의 + 코드 검증 추가
+10. P-7: catalog.yaml에 zone별 max_chars 추가
+11. P-8: slide_measurer.py에서 블록 내부 overflow까지 체크
+
+### M-Step 4: 원본 보존 (P-9)
+
+12. 편집자에게 꼭지별 source_text 직접 전달
+13. "추출만. 재작성 금지." 강화
+
+---
+
+## 기술 조사 결과
+
+### M-Step 1에 필요한 것
+
+| 항목 | 현재 | 변경 | 도구 |
+|------|------|------|------|
+| KEI_PROMPT 출력 | topics만 | + page_structure (본심/배경/첨부/결론 + weight) | 프롬프트 수정 |
+| page_structure 파싱 | 없음 | `analysis.get("page_structure")` | 코드 추가 |
+| PURPOSE_WEIGHT 상수 | 하드코딩 (space_allocator.py) | **제거** → Kei weight 사용 | 코드 수정 |
+| allocate_height_budget() | PURPOSE_WEIGHT 참조 | weight_override 파라미터 추가 | 함수 시그니처 변경 |
+| STEP_B_PROMPT | purpose별 규칙만 | + weight 기반 블록 크기 지시 | 프롬프트 수정 |
+
+**충돌:** 없음. page_structure는 새 필드. PURPOSE_WEIGHT 제거는 개선.
+**Kei vs Sonnet:** Kei가 weight 판단. Sonnet은 weight를 **받아서** 블록 크기 결정.
+
+---
+
+### M-Step 2에 필요한 것
+
+| 항목 | 현재 | 변경 | 도구 |
+|------|------|------|------|
+| FAISS 쿼리 | title+summary+role+layer | + purpose + relation_type + expression_hint | block_search.py `_build_query()` 수정 |
+| STEP_B_PROMPT 가이드 | purpose 6종 허용/금지 | 역할(본심/배경/첨부) × 성격(비교/정의/구조) 매트릭스 | 프롬프트 확장 |
+
+**충돌:** Phase K purpose 가이드 **위에** 매트릭스 확장. 기존 규칙 유지.
+
+---
+
+### M-Step 3에 필요한 것
+
+| 항목 | 현재 | 변경 | 도구 |
+|------|------|------|------|
+| P-5 Figma 식별 | 구분 없음 | catalog.yaml에 `figma_source` 필드 | YAML 수정 |
+| P-6 zone 적합성 | sidebar 1열만 (J-6) | **블록-zone 적합성 맵** 코드 검증 | 신규 상수 + 검증 로직 |
+| P-7 글자 수용량 | slot_desc 의미만 | catalog.yaml에 **zone별 max_chars** | YAML + 편집자 연동 |
+| P-8 내부 overflow | zone 레벨만 측정 | **블록 내부** scrollHeight 체크 | slide_measurer.py JS 확인 |
+
+**P-6 블록-zone 적합성 맵:**
+```python
+# 신규 상수 (design_director.py 또는 별도 모듈)
+SIDEBAR_SAFE_BLOCKS = {
+ "card-numbered", "card-step-vertical",
+ "banner-gradient", "callout-solution", "callout-warning",
+ "dark-bullet-list", "divider-text", "highlight-strip",
+ "quote-question", "tab-label-row",
+ "topic-left-right", "topic-numbered",
+ "table-simple-striped", "process-horizontal",
+ "image-before-after", "image-grid-2x2", "image-row-2col",
+}
+
+FULL_WIDTH_ONLY_BLOCKS = {
+ "card-compare-3col", "card-dark-overlay", "card-icon-desc",
+ "card-image-3col", "card-image-round", "card-stat-number", "card-tag-image",
+ "section-title-with-bg", "section-header-bar", "topic-center",
+ "quote-big-mark", "image-full-caption",
+ "compare-2col-split", "compare-pill-pair", "comparison-2col",
+}
+```
+
+**충돌:** Phase J의 sidebar 1열 강제와 **보완 관계.** J-6은 열 수 제한, M-Step 3은 블록 자체 제한.
+
+---
+
+### M-Step 4에 필요한 것
+
+| 항목 | 현재 | 변경 | 도구 |
+|------|------|------|------|
+| 원본 전달 | 전체 content 한 번에 | **토픽별 source_text 추출하여 전달** | fill_content() 수정 |
+| source_hint | 정의됨, 사용 안 됨 | **편집자에게 전달** | 프롬프트 수정 |
+| source_data | 텍스트 설명만 | **실제 원본 텍스트 추출 참조** | 코드 추가 |
+| 재작성 방지 | "보존" 원칙만 | **"추출만. 재작성 금지."** 절대 규칙 | 프롬프트 강화 |
+
+**충돌:** Phase J source 규칙 **유지 + 보강.**
+
+---
+
+## 실행 방안 상세
+
+### M-Step 1: Kei 비중 시스템
+
+#### M-1a: KEI_PROMPT 출력 스키마 변경
+
+**위치:** `src/kei_client.py` KEI_PROMPT (20~79행)
+
+**추가할 출력 필드:**
+```json
+{
+ "title": "...",
+ "core_message": "...",
+ "page_structure": {
+ "본심": {"topic_ids": [2, 3], "weight": 0.60},
+ "배경": {"topic_ids": [1], "weight": 0.15},
+ "첨부": {"topic_ids": [4], "weight": 0.15},
+ "결론": {"topic_ids": [5], "weight": 0.10}
+ },
+ "topics": [...]
+}
+```
+
+**프롬프트에 추가할 지시:**
+```
+## 4단계: 페이지 구조 판단
+콘텐츠를 분석하여 이 페이지의 구조를 판단하라:
+- **본심**: 이 페이지가 말하려는 핵심. 가장 큰 공간을 차지해야 함.
+- **배경**: 본심을 이해하기 위한 도입/배경. 간결하게.
+- **첨부**: 본심을 보조하는 참조 정보 (용어 정의 등). sidebar 배치.
+- **결론**: 절대 잊으면 안 되는 핵심 한 줄. footer.
+
+각 역할에 해당하는 topic_ids와 공간 비중(weight, 합계 1.0)을 결정하라.
+콘텐츠에 따라 비중은 매번 달라진다. 고정값이 아니다.
+```
+
+**충돌:** 없음. 기존 출력 필드에 page_structure 추가만. `.get()` 방식이라 무시 가능.
+
+#### M-1b: pipeline.py에서 Kei weight 읽기
+
+**위치:** `src/pipeline.py` Phase L 공간 할당 부분 (현재 132~165행)
+
+**변경:** PURPOSE_WEIGHT 대신 Kei 출력 weight 사용
+```python
+# 현재 (Phase L 하드코딩):
+allocation = allocate_height_budget(zone_blocks, zone_info.get("budget_px", 490))
+
+# 변경 (Phase M Kei 판단):
+page_struct = analysis.get("page_structure", {})
+weight_map = {}
+for role_name, role_info in page_struct.items():
+ for tid in role_info.get("topic_ids", []):
+ weight_map[tid] = role_info.get("weight", 0.25)
+allocation = allocate_height_budget(
+ zone_blocks, zone_info.get("budget_px", 490),
+ weight_override=weight_map
+)
+```
+
+#### M-1c: allocate_height_budget() 시그니처 변경
+
+**위치:** `src/space_allocator.py` (42~75행)
+
+**변경:** `weight_override` 파라미터 추가
+```python
+def allocate_height_budget(
+ blocks, zone_budget_px, gap_px=20,
+ weight_override=None, # {topic_id: weight} — Kei 판단 기반
+):
+ # weight_override 있으면 사용, 없으면 PURPOSE_WEIGHT fallback
+ for block in blocks:
+ tid = block.get("topic_id")
+ if weight_override and tid in weight_override:
+ weight = weight_override[tid]
+ else:
+ purpose = block.get("purpose", "")
+ weight = PURPOSE_WEIGHT.get(purpose, 0.25)
+ weights.append(weight)
+```
+
+**PURPOSE_WEIGHT:** fallback으로 유지 (Kei가 page_structure 안 줬을 때). 하드코딩 → fallback 강등.
+
+#### M-1d: STEP_B_PROMPT에 weight 전달
+
+**위치:** `src/design_director.py` STEP_B_PROMPT user_prompt 구성부
+
+**추가:** Kei가 판단한 비중을 팀장에게 전달
+```
+## 페이지 구조 (Kei 실장 판단)
+- 본심 (꼭지 2, 3): 공간 비중 60% — body에서 가장 크게
+- 배경 (꼭지 1): 공간 비중 15% — compact 도입부
+- 첨부 (꼭지 4): 공간 비중 15% — sidebar 참조
+- 결론 (꼭지 5): 공간 비중 10% — footer 한 줄
+
+본심에 가장 큰 블록을, 배경에 가장 작은 블록을 배정하라.
+비중을 무시하고 동등하게 배치하지 마라.
+```
+
+---
+
+### M-Step 2: 역할-블록 매핑
+
+#### M-2a: FAISS 쿼리 강화
+
+**위치:** `src/block_search.py` `_build_query()` (178~188행)
+
+**변경:** purpose + relation_type + expression_hint 추가
+```python
+def _build_query(topic):
+ parts = [
+ topic.get("title", ""),
+ topic.get("summary", ""),
+ f"역할: {topic.get('role', 'flow')}",
+ f"레이어: {topic.get('layer', 'core')}",
+ f"목적: {topic.get('purpose', '')}", # 추가
+ f"관계: {topic.get('relation_type', '')}", # 추가
+ f"표현: {topic.get('expression_hint', '')}", # 추가
+ ]
+ if topic.get("content_type"):
+ parts.append(f"콘텐츠: {topic['content_type']}")
+ return ". ".join(p for p in parts if p)
+```
+
+#### M-2b: STEP_B_PROMPT 역할×성격 매트릭스
+
+**위치:** `src/design_director.py` purpose 가이드 섹션
+
+**기존 Phase K 규칙 유지 + 아래 매트릭스 추가:**
+```
+## 역할 × 콘텐츠 성격 블록 매트릭스
+
+| 역할 | 비교(comparison) | 구조(hierarchy/inclusion) | 정의(definition) | 흐름(sequence) |
+|------|-----------------|------------------------|-----------------|---------------|
+| 본심 | compare-2col-split, compare-3col-badge | keyword-circle-row, venn-diagram | card-numbered(large) | process-horizontal, flow-arrow-horizontal |
+| 배경 | topic-left-right(compact) | topic-left-right(compact) | quote-question(compact) | topic-left-right(compact) |
+| 첨부 | card-numbered(sidebar) | card-numbered(sidebar) | card-numbered(sidebar), dark-bullet-list(sidebar) | card-numbered(sidebar) |
+| 결론 | banner-gradient | banner-gradient | banner-gradient | banner-gradient |
+```
+
+---
+
+### M-Step 3: 블록 안전성
+
+#### M-3a: catalog.yaml figma_source 필드 (P-5)
+
+**추가할 필드:** 각 블록에 `figma_source: true/false`
+
+#### M-3b: zone 적합성 검증 (P-6)
+
+**위치:** `src/design_director.py` `_validate_height_budget()` 내
+
+**추가:** sidebar에 FULL_WIDTH_ONLY_BLOCKS 배치 시 교체/경고
+
+#### M-3c: 글자 수용량 (P-7)
+
+**위치:** `templates/catalog.yaml`
+
+**추가:** 각 블록에 zone별 max_chars
+```yaml
+- id: compare-2col-split
+ max_chars:
+ body: {left: 200, right: 200, criteria: 30} # 65% 너비 기준
+ sidebar: null # sidebar 사용 불가
+```
+
+#### M-3d: 내부 overflow 감지 (P-8)
+
+**위치:** `src/slide_measurer.py` _MEASURE_SCRIPT
+
+**확인:** 현재 JS가 블록 내부 `scrollHeight > clientHeight + 2` 이미 체크 중.
+`overflow: auto` 블록(compare-3col-badge)의 수평 스크롤도 `scrollWidth > clientWidth` 체크 추가.
+
+---
+
+### M-Step 4: 원본 보존
+
+#### M-4a: 토픽별 source_text 추출
+
+**위치:** `src/pipeline.py` Stage 3 호출 전
+
+**추가:** Kei가 출력한 source_hint + source_data를 기반으로 원본에서 텍스트 추출
+```python
+# 토픽별 원본 텍스트 매핑 구성
+topic_sources = {}
+for topic in analysis.get("topics", []):
+ source_hint = topic.get("source_hint", "")
+ source_data = topic.get("source_data", "")
+ topic_sources[topic["id"]] = {
+ "hint": source_hint,
+ "data": source_data,
+ }
+```
+
+#### M-4b: fill_content() 프롬프트에 토픽별 source 전달
+
+**위치:** `src/content_editor.py` fill_content() user_prompt 구성부
+
+**추가:**
+```
+## 토픽별 원본 데이터 (이 텍스트에서 추출하라. 재작성 금지.)
+- 토픽 1: [source_hint 내용]
+- 토픽 2: [source_hint 내용]
+```
+
+---
+
+## 충돌/회귀/하드코딩 최종 검증
+
+| Step | 충돌 | 회귀 | 하드코딩 | Kei/Sonnet |
+|------|:---:|:---:|:------:|:----------:|
+| M-1a KEI_PROMPT | 없음 | 없음 | **Kei 판단** | Kei |
+| M-1b pipeline weight | 없음 | Phase L 개선 | **Kei weight** | — |
+| M-1c allocate 시그니처 | 없음 | 없음 | fallback만 | — |
+| M-1d STEP_B weight | 없음 | 없음 | **Kei → 팀장** | Sonnet(기존) |
+| M-2a FAISS 쿼리 | 없음 | 없음 | 없음 | — |
+| M-2b 매트릭스 | Phase K 위에 확장 | 없음 | 없음 | Sonnet(기존) |
+| M-3a Figma | 없음 (신규) | 없음 | 없음 | — |
+| M-3b zone맵 | Phase J 보강 | 없음 | 상수(범용) | — |
+| M-3c max_chars | Phase I 보강 | 없음 | 없음 | — |
+| M-3d 내부overflow | Phase L 확장 | 없음 | 없음 | — |
+| M-4a source 추출 | 없음 (신규) | 없음 | 없음 | — |
+| M-4b 편집자 강화 | Phase J 보강 | 없음 | 없음 | Kei(편집자) |
+
+---
+
+## 이력
+
+| 날짜 | 내용 |
+|------|------|
+| 2026-03-26 | Phase I~L 전체 실행 후 결과물 분석. 외부 진단(P-1~P-9) 수용. 비중 시스템(Kei 판단, 하드코딩 아님) 기반 전면 재설계. Phase M 계획 수립. |
+| 2026-03-26 | 기술 조사 + 충돌/회귀 정밀 검토 완료. M-Step 1~4 실행 방안 상세 확정. |
diff --git a/IMPROVEMENT-PHASE-N.md b/IMPROVEMENT-PHASE-N.md
new file mode 100644
index 0000000..22301d9
--- /dev/null
+++ b/IMPROVEMENT-PHASE-N.md
@@ -0,0 +1,565 @@
+# Phase N: 4대 핵심 문제 진단 + 해결 방안
+
+> 작성일: 2026-03-27
+> 상태: ✅ 완료 — catalog 개선, fallback 전면 제거, topic_id 버그 수정, 무한 재시도 체계 구축
+
+---
+
+## 오답 노트 (절대 반복 금지)
+
+아래는 이미 실패가 증명된 접근법이다. **어떤 상황에서도 다시 사용하지 않는다.**
+
+| # | 실패 패턴 | 왜 실패했나 | 교훈 |
+|---|----------|-----------|------|
+| X-1 | Sonnet에게 블록 선택을 맡김 | Kei 추천을 무시하고 자기 맘대로 바꿈. 프롬프트로 제어 불가 | 블록 선택은 Kei 권한. 코드 레벨 강제. |
+| X-2 | Sonnet fallback (Kei 실패 시 Sonnet 대체) | Sonnet이 대체해봤자 품질이 안 나옴. 결과물이 무의미 | Kei API는 필수 인프라. 실패 시 파이프라인 중단. fallback 자체가 없음. |
+| X-3 | max-height + overflow:hidden으로 CSS 사후 자르기 | 텍스트가 잘리는데 측정기가 "정상"이라고 판단. 근본적 결함 | 콘텐츠는 렌더링 전에 맞춰야 함. CSS로 사후에 자르지 않음. |
+| X-4 | HTML 텍스트를 읽고 시각 검수 | Kei가 HTML 소스를 읽어봤자 렌더링 결과를 알 수 없음. 10분 낭비 | 시각 검수는 스크린샷(이미지)으로. |
+| X-5 | "안전망/fallback"이라는 명목으로 실패 패턴 재도입 | 실패한 방법을 "비상용"이라고 다시 넣으면 결국 그게 돌아감 | 실패한 것은 비상용으로도 안 됨. 오답 노트에 기록하고 근절. |
+| X-6 | 프롬프트만으로 LLM 행동 강제 | "반드시 존중하라"고 써도 LLM은 안 지킴 | 강제는 코드로. 프롬프트는 가이드일 뿐. |
+
+---
+
+## 문제 전체 요약
+
+| # | 문제 | 원인 위치 | 심각도 |
+|---|------|----------|--------|
+| N-1 | 블록 선택이 콘텐츠 전달 방식과 안 맞음 | `design_director.py` Step B | **치명** |
+| N-2 | 사이드바에 섹션 제목이 없음 | `kei_client.py` + `renderer.py` | 중간 |
+| N-3 | max-height CSS가 콘텐츠를 잘라먹음 | `renderer.py` 229-235행 | **치명** |
+| N-4 | Stage 5가 HTML 텍스트를 읽어서 무용지물 | `kei_client.py` + `pipeline.py` | **치명** |
+
+---
+
+## N-1. 블록 선택이 콘텐츠 전달 방식과 안 맞음
+
+### 현상
+- Kei 실장(Opus)이 1단계에서 `expression_hint`, `relation_type`을 판단함
+- 2단계 Step A-2에서 Kei가 블록을 추천함 (`_opus_block_recommendation()`)
+- **그런데 Step B에서 Sonnet이 Kei 추천을 무시하고 자기 맘대로 블록을 바꿈**
+- 프롬프트에 "Opus 추천 존중" 규칙을 넣어도 Sonnet이 안 지킴
+
+### 원인 (코드 레벨)
+
+**`design_director.py` — Step B 흐름:**
+```
+Step A: rule-based preset 선택 (sidebar-right 등)
+Step A-2: Kei API로 블록 추천 받음 → opus_blocks[]
+Step B: Sonnet이 zone 배치 + char_guide 결정
+ ↑ 여기서 Sonnet이 블록 타입을 바꿔버림
+```
+
+`STEP_B_PROMPT`에 "Opus가 추천한 블록을 존중하라"고 적어놨지만, **프롬프트는 강제가 아니다.**
+Sonnet은 "더 적절하다"고 판단하면 얼마든지 다른 블록을 선택한다.
+
+### 해결 방안: Kei가 블록을 결정, Sonnet은 zone + char_guide만
+
+**핵심 원칙:** 블록 선택 = Kei 권한. 코드 레벨 강제. 프롬프트 의존 안 함.
+
+**변경 대상:** `design_director.py`
+
+```
+현재 흐름:
+ Step A: preset 선택
+ Step A-2: Kei 블록 추천 (참고용)
+ Step B: Sonnet이 블록 + zone + char_guide 전부 결정
+
+변경 후:
+ Step A: preset 선택
+ Step A-2: Kei가 블록 확정 (topic_id → block_type 매핑)
+ Step B: Sonnet은 zone 배치 + char_guide만 결정 (block_type 변경 금지)
+```
+
+**구체적 변경:**
+
+1. **Step A-2 (`_opus_block_recommendation`)**: Kei API 응답에서 받은 블록을 "추천"이 아닌 "확정"으로 처리
+ - 반환값: `{topic_id: block_type}` 딕셔너리
+ - 이 딕셔너리를 Step B에 **읽기 전용**으로 전달
+
+2. **Step B 프롬프트 변경**: `STEP_B_PROMPT`에서 블록 선택 지시 제거
+ - "각 꼭지에 맞는 블록을 선택하라" → 삭제
+ - "아래 확정된 블록의 zone 배치와 글자 수 가이드만 결정하라"로 변경
+
+3. **Step B 후처리 (코드 강제)**:
+ ```python
+ # Sonnet 응답 후, 블록 타입을 Kei 확정값으로 덮어쓰기
+ for block in sonnet_blocks:
+ tid = block.get("topic_id")
+ if tid in kei_confirmed_blocks:
+ block["type"] = kei_confirmed_blocks[tid] # 코드 레벨 강제
+ ```
+ - Sonnet이 어떤 블록을 응답하든, topic_id에 매칭되는 Kei 확정 블록으로 강제 교체
+ - Sonnet의 zone, char_guide, reason만 살림
+
+4. **Kei API는 필수 의존성:** 실패 시 fallback 없음. 파이프라인 중단 + 에러 반환.
+ - Kei API(localhost:8000)는 항상 떠 있어야 하는 로컬 인프라
+ - 안 되면 그건 버그. 대체 경로가 아니라 수정 대상.
+
+**사용 기술:**
+- 기존 Kei API (`_opus_block_recommendation`) — 이미 존재
+- Python dict 매핑으로 코드 레벨 강제 — 새 도구 불필요
+- `STEP_B_PROMPT` 프롬프트 축소 — zone + char_guide만
+
+---
+
+## N-2. 사이드바에 섹션 제목이 없음
+
+### 현상
+- 사이드바에 "용어 정의" 같은 콘텐츠가 배치되는데
+- 그게 뭔지 알려주는 섹션 제목이 없음
+- 독자가 사이드바가 무엇인지 맥락을 모름
+
+### 원인 (코드 레벨)
+
+1. **Kei 1단계 (`KEI_PROMPT`)**: `role: "reference"` + `purpose: "용어정의"`는 출력하지만, **section_title** 필드가 없음
+2. **design_director.py Step B**: sidebar zone에 블록을 배치할 때 섹션 제목 블록을 안 넣음
+3. **renderer.py**: area div를 렌더할 때 영역 라벨 없이 바로 블록 HTML만 출력
+
+### 해결 방안: Kei가 section_title 판단 + 렌더러가 표시
+
+**변경 대상:** `kei_client.py`, `design_director.py`, `renderer.py`
+
+1. **Stage 1 Kei 프롬프트 (`KEI_PROMPT`) 확장:**
+ - 기존 topic 필드에 `section_title` 추가
+ - `role: "reference"`인 꼭지에 Kei가 "용어 정의", "참고 자료" 등 섹션 제목을 부여
+ - 출력 JSON 예시:
+ ```json
+ {"id": 4, "title": "용어 혼용 정리", "purpose": "용어정의",
+ "role": "reference", "section_title": "용어 정의"}
+ ```
+
+2. **Step B 블록 배치에 section label 블록 자동 삽입:**
+ - sidebar zone에 reference 블록이 배치될 때
+ - 해당 topic의 `section_title`이 있으면 → `topic-center` 또는 `divider-text` 블록을 자동 삽입
+ - 이것은 **코드 레벨** (Sonnet 판단 아님)
+
+3. **renderer.py `_group_blocks_by_area()`에서 sidebar 처리:**
+ - sidebar area 그룹에 section label이 있으면 최상단에 배치
+ - CSS: 작은 글씨 + 볼드 + 하단 구분선
+
+**사용 기술:**
+- KEI_PROMPT JSON 스키마 확장 (section_title 필드 1개)
+- 기존 블록 (`divider-text` 또는 `topic-center`) 재활용
+- renderer.py 코드 로직으로 자동 삽입
+
+---
+
+## N-3. max-height CSS가 콘텐츠를 잘라먹음
+
+### 현상
+- 렌더된 HTML에서 텍스트가 중간에 뚝 잘려 보임
+- Selenium으로 측정하면 "overflow 없음"이라고 나옴 → 실제로는 잘리고 있는데 감지 못함
+- 결과: Phase L 피드백 루프가 "정상"으로 판단하고 넘어감 → 잘린 채로 최종 출력
+
+### 원인 (코드 레벨)
+
+**`renderer.py` 229-235행:**
+```python
+# Phase L: 블록별 max-height 제약
+max_height = block.get("_max_height_px")
+if max_height:
+ rendered_html = (
+ f'
'
+ f'{rendered_html}
'
+ )
+```
+
+이게 하는 일:
+1. 블록에 `max-height: Npx; overflow: hidden` CSS를 씌움
+2. → 콘텐츠가 N px을 넘으면 **시각적으로 잘림**
+3. → `overflow: hidden`이므로 `scrollHeight === clientHeight` → **측정기가 "overflow 없음"으로 판단**
+4. → 피드백 루프가 작동 안 함 → 잘린 채 확정
+
+**근본 원인:** 텍스트가 공간에 맞는지를 CSS로 사후에 자르는 게 아니라, **편집 단계에서 글자 수를 맞춰야 한다.**
+
+### 해결 방안: max-height 제거 + 편집자에게 _max_chars 강제 전달
+
+**핵심 원칙:**
+- 콘텐츠가 렌더링 전에 공간에 맞아야 한다 (fit before render)
+- CSS로 사후에 자르지 않는다
+- overflow는 측정으로 감지하고, 감지되면 편집자를 다시 호출한다
+
+**변경 대상:** `renderer.py`, `content_editor.py`, `slide_measurer.py`
+
+### 변경 1: renderer.py에서 max-height 래퍼 제거
+
+```python
+# 229-235행 삭제. 아래 코드 완전 제거:
+max_height = block.get("_max_height_px")
+if max_height:
+ rendered_html = (
+ f'
'
+ f'{rendered_html}
'
+ )
+```
+
+max-height 없이 렌더링 → overflow가 생기면 `scrollHeight > clientHeight`로 정확히 감지됨.
+
+### 변경 2: content_editor.py 프롬프트에 _max_chars 강제 명시
+
+현재 `EDITOR_PROMPT`의 purpose별 분량 원칙이 "가이드라인" 수준.
+`_max_chars`가 계산되어 있지만 편집자에게 전달이 안 되고 있음.
+
+```python
+# fill_content()에서 각 블록의 _max_chars를 프롬프트에 명시
+req_text += f"\n **최대 글자 수 (절대 제한): {block.get('_max_chars', '없음')}자**"
+req_text += f"\n 이 글자 수를 넘기면 슬라이드에서 잘린다. 반드시 지켜라."
+```
+
+### 변경 3: slide_measurer.py의 overflow 감지 정상화
+
+max-height + overflow:hidden이 없어지면, 기존 측정 스크립트가 정상 작동:
+```javascript
+// scrollHeight > clientHeight → 정확한 overflow 감지
+overflowed: zone.scrollHeight > zone.clientHeight + 2
+```
+
+현재 `_MEASURE_SCRIPT`는 이미 이 로직을 갖고 있음. max-height만 제거하면 됨.
+
+**추가: overflow:visible 확인**
+- CSS에서 zone/block 컨테이너에 `overflow: hidden`이 없는지 확인
+- `base.css`에 혹시 hidden이 있으면 제거
+- 기본값 `overflow: visible`이면 scrollHeight 측정이 정확
+
+### 변경 4: Phase L 피드백 루프 강화
+
+현재 `pipeline.py` 215-275행의 피드백 루프:
+1. 측정 → overflow 감지 → char_guide 축소 → 편집자 재호출 → 재렌더링
+2. 최대 3회 반복
+
+**수정사항:**
+- char_guide 축소 대신 `_max_chars` 직접 축소 (더 정확)
+- 축소량: `calculate_trim_chars(excess_px)` 결과를 `_max_chars`에서 차감
+- 편집자 재호출 시 축소된 `_max_chars`를 프롬프트에 명시
+
+**사용 기술:**
+- 기존 Selenium + `scrollHeight > clientHeight` — 이미 존재, max-height만 제거하면 작동
+- 기존 `calculate_max_chars()`, `calculate_trim_chars()` — 이미 존재
+- `content_editor.py` 프롬프트 확장 — `_max_chars` 전달만 추가
+- **새 도구 불필요**
+
+---
+
+## N-4. Stage 5가 HTML 텍스트를 읽어서 무용지물
+
+### 현상
+- Kei 실장이 최종 검수 (Stage 5)에서 10분 걸리는데 아무것도 안 바뀜
+- 이유: Kei가 **HTML 소스 텍스트**를 읽고 검수함
+- HTML 태그 사이에서 실제 렌더링 결과를 상상해야 함 → 불가능
+- "텍스트가 잘리는지", "비중이 맞는지", "가독성이 괜찮은지" → HTML 텍스트로는 판단 불가
+
+### 원인 (코드 레벨)
+
+**`kei_client.py` `call_kei_final_review()` 306-313행:**
+```python
+prompt = (
+ KEI_REVIEW_PROMPT + "\n\n"
+ f"## 핵심 메시지\n{core_message}\n\n"
+ ...
+ f"\n\n## 조립 HTML (요약)\n{html[:3000]}\n\n" # ← HTML 소스 텍스트 3000자
+ f"위 결과물을 검수하고 조정이 필요한지 판단해. JSON만."
+)
+```
+
+Kei(Opus)는 멀티모달 모델이라 이미지를 볼 수 있는데, **현재는 텍스트만 전달.**
+
+### 해결 방안: Selenium 스크린샷 → Kei API에 이미지 전달
+
+**핵심 원칙:**
+- Stage 5에서 Kei가 **실제 렌더링된 슬라이드 스크린샷**을 보고 검수
+- HTML 텍스트 읽기 → 이미지 보기로 전환
+- overflow 없으면 Stage 5 건너뜀 (시간 절약)
+- 최대 1회만 (현재 2회 → 1회)
+
+### 기술 조사 결과
+
+#### Selenium 스크린샷 → base64
+
+```python
+from selenium import webdriver
+from selenium.webdriver.chrome.options import Options
+from selenium.webdriver.common.by import By
+
+options = Options()
+options.add_argument("--headless=new")
+options.add_argument("--window-size=1280,720")
+options.add_argument("--force-device-scale-factor=1")
+
+driver = webdriver.Chrome(options=options)
+driver.get(f"data:text/html;charset=utf-8,{encoded_html}")
+
+# 슬라이드 요소만 정확히 캡처
+slide = driver.find_element(By.CSS_SELECTOR, ".slide")
+screenshot_b64 = slide.screenshot_as_base64 # str, 순수 base64
+driver.quit()
+```
+
+**API 출처:** Selenium 4.x `WebElement.screenshot_as_base64` 프로퍼티
+- 반환: `str` (순수 base64, data URI prefix 없음)
+- 형식: PNG
+- 해당 요소의 bounding box만 캡처 (전체 페이지가 아님)
+
+#### Anthropic Claude API 이미지 전달 형식
+
+```python
+import anthropic
+
+client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
+response = await client.messages.create(
+ model="claude-opus-4-0-20250514", # Opus = 멀티모달 지원
+ max_tokens=4096,
+ messages=[{
+ "role": "user",
+ "content": [
+ {
+ "type": "image",
+ "source": {
+ "type": "base64",
+ "media_type": "image/png",
+ "data": screenshot_b64, # 순수 base64 문자열
+ },
+ },
+ {
+ "type": "text",
+ "text": "이 슬라이드를 검수해줘. ...",
+ },
+ ],
+ }],
+)
+```
+
+**API 출처:** Anthropic 공식 Vision 문서
+- 지원 모델: Claude Opus 4, Sonnet 4, Haiku 3.5 전부 멀티모달 지원
+- 지원 포맷: PNG, JPEG, GIF, WebP
+- 이미지 크기 제한: 최대 8000x8000px, 5MB/장
+- 1280x720 슬라이드: ~1,229 토큰 (비용 미미)
+
+#### 문제: 현재 Kei API(`/api/message`)는 이미지 미지원
+
+**Kei persona_agent 조사 결과:**
+- `ChatRequest` 모델: `message: str` (텍스트만)
+- 이미지 필드 없음
+- LLM 호출 시 messages를 `{"role": "user", "content": str}`로 전달
+
+**필요한 변경 (Kei persona_agent 측):**
+
+```python
+# ChatRequest 확장 (persona_agent/backend/main.py)
+class ChatRequest(BaseModel):
+ session_id: str | None = None
+ message: str
+ image_data: str | None = None # base64 이미지 (선택)
+ image_media_type: str | None = None # "image/png" 등 (선택)
+```
+
+- 4개 파일, ~50줄 변경
+- 기존 텍스트 요청은 깨지지 않음 (image 필드는 optional)
+- Anthropic SDK는 이미 이미지 content block 지원 → 그대로 전달만 하면 됨
+
+### 전체 Stage 5 변경 흐름
+
+```
+현재:
+ Phase L 측정 → Stage 5: Kei가 HTML 텍스트 3000자 읽기 → 조정
+
+변경 후:
+ Phase L 측정 → overflow 없으면 Stage 5 건너뜀 (시간 절약)
+ → overflow 있으면:
+ 1. Selenium으로 슬라이드 스크린샷 (base64 PNG)
+ 2. 스크린샷 + 측정 데이터 → Kei API (이미지 포함)
+ 3. Kei가 실제 렌더링 보고 판단 → 조정 지시
+ 4. 최대 1회 (현재 2회에서 축소)
+```
+
+**변경 대상:**
+- `kei_client.py`: `call_kei_final_review()`에 이미지 전달 추가
+- `pipeline.py`: Stage 5에 스크린샷 촬영 + overflow 없으면 skip 로직
+- `slide_measurer.py`: 스크린샷 캡처 함수 추가 (`capture_slide_screenshot()`)
+- Kei persona_agent: ChatRequest에 image 필드 추가 (4파일 ~50줄)
+
+**주의:** Kei persona_agent 코드를 수정해야 함 → 사용자 승인 필요
+
+### 대안: Kei API 변경 없이 Anthropic 직접 호출
+
+Kei API 수정이 부담스러우면, Stage 5만 Anthropic API 직접 호출 가능:
+- `anthropic.AsyncAnthropic`으로 Opus 직접 호출
+- Kei 페르소나 시스템 프롬프트를 `personas/kei.md`에서 로드하여 system으로 전달
+- **단점:** Kei의 RAG/세션 컨텍스트를 못 씀
+- **장점:** persona_agent 수정 없음
+
+---
+
+## 실행 순서 (의존 관계)
+
+```
+N-3 (max-height 제거) ← 가장 먼저. 다른 것과 독립.
+ │
+ ├→ N-1 (블록 선택 강제) ← N-3과 독립. 병렬 가능.
+ │
+ ├→ N-2 (사이드바 제목) ← N-1 완료 후 (블록 확정 후 제목 삽입)
+ │
+ └→ N-4 (스크린샷 검수) ← N-3 완료 필수 (overflow 감지 정상화 후)
+```
+
+**추천 순서:**
+1. **N-3** — max-height 제거 + _max_chars 편집자 전달 (즉시, 가장 급함)
+2. **N-1** — 블록 선택 코드 강제 (N-3과 병렬 가능)
+3. **N-2** — 사이드바 섹션 제목 (N-1 후)
+4. **N-4** — 스크린샷 기반 검수 (N-3 후, Kei API 수정 필요)
+
+---
+
+## 충돌 / 회귀 / 오류 검토
+
+### 검토 방법
+- 4개 변경의 모든 수정 대상 파일을 코드 레벨로 읽고 교차 검증
+- `overflow: hidden` 전수 조사 (`.py`, `.css`, `.html` 전체)
+- `_max_height_px`, `_max_chars` 참조 전수 조사
+- 각 변경 간 의존 관계 + 실행 순서에서의 충돌 가능성 점검
+
+---
+
+### N-3 (max-height 제거) — 충돌 분석
+
+**`overflow: hidden`이 존재하는 3개 레이어:**
+
+| 위치 | 값 | 용도 | 건드리나 |
+|------|-----|------|---------|
+| `.slide` (base.css:16) | `overflow: hidden` | 1280x720 프레임 바깥 차단 | **유지 (건드리지 않음)** |
+| `.slide > div` (base.css:76) | `overflow: visible` | area div (body, sidebar 등) | 이미 visible. 변경 불필요 |
+| `renderer.py:229-235` | `max-height:Npx; overflow:hidden` | 블록별 래퍼 | **이것만 제거** |
+
+**개별 블록 템플릿의 `overflow: hidden` (15개+):**
+- `card-image-3col.html`, `card-dark-overlay.html`, `venn-diagram.html` 등
+- 이것은 이미지/카드의 `border-radius` 잘림용
+- **텍스트 clipping과 무관 → 건드리지 않음**
+
+**Phase L 측정기 영향:**
+- max-height 래퍼 제거 후, `scrollHeight`가 실제 콘텐츠 높이를 정확 반영
+- `_MEASURE_SCRIPT`의 `block.scrollHeight > block.clientHeight` → **정상 작동**
+- 이전에 false-negative(잘렸는데 감지 못함)이던 것이 정상 감지됨
+- Phase L 루프가 더 자주 트리거될 수 있음 → **의도한 동작** (잘리는 걸 고치는 것)
+- MAX_MEASURE_ROUNDS = 3이면 충분
+
+**회귀 위험:** 없음. max-height 래퍼는 Phase L에서 추가된 것이고, 제거해도 기존 블록/CSS에 영향 없음.
+
+---
+
+### N-1 (블록 선택 강제) — 충돌 분석
+
+**기존 Step B 후처리 체인 (design_director.py:819-850):**
+```
+현재: Sonnet 응답 → 미등록 블록 거부 → area명 검증 → conclusion→footer 강제
+추가: Sonnet 응답 → ★Kei 확정 블록 덮어쓰기 → 미등록 블록 거부 → area명 검증 → conclusion→footer 강제
+```
+
+| 시나리오 | 처리 |
+|----------|------|
+| Kei가 추천한 블록이 catalog에 없음 | 바로 다음 단계에서 미등록 검증 → PURPOSE_FALLBACK 교체 |
+| Kei가 추천한 블록이 sidebar 금지 | `_validate_height_budget()`의 SIDEBAR_FORBIDDEN_BLOCKS 체크 |
+| Kei API 미응답 | **파이프라인 중단 + 에러 반환. fallback 없음.** Kei API는 필수 인프라. |
+
+**N-3과의 관계:** 독립. N-1은 2단계, N-3은 4단계. 서로 다른 파이프라인 단계.
+
+**회귀 위험:** 없음. 기존 검증 체인 위에 한 단계 추가할 뿐.
+
+---
+
+### N-2 (사이드바 제목) — 충돌 분석
+
+| 시나리오 | 위험 | 대응 |
+|----------|------|------|
+| label 블록 추가 → sidebar 높이 예산 초과 | 낮음 (label ~30px, 예산 490px) | label 블록은 고정 30px, allocate 제외 |
+| N-1 미완료 상태에서 실행 | Sonnet이 블록을 바꿔서 label 위치 엉뚱 | **실행 순서: N-1 먼저, N-2 나중** |
+| `_group_blocks_by_area()` 호환 | flex-column 최상단에 자연 배치 | 호환 문제 없음 |
+
+**회귀 위험:** 없음. 기존 로직에 label 삽입만 추가.
+
+---
+
+### N-4 (스크린샷 검수) — 충돌 분석
+
+| 시나리오 | 위험 | 대응 |
+|----------|------|------|
+| N-3 미완료 → overflow 감지 부정확 | "overflow 없으면 skip" 판단이 틀림 | **실행 순서: N-3 먼저, N-4 나중** |
+| Selenium 인스턴스 충돌 | Phase L에서 quit 후 Stage 5에서 새 생성 | 동시 사용 아님, 충돌 없음 |
+| Kei persona_agent 미수정 | 이미지 전달 불가 | 대안: Anthropic 직접 호출 (persona 프롬프트 파일에서 로드) |
+| MAX_REVIEW_ROUNDS 2→1 축소 | 기존보다 조정 기회 줄어듦 | 스크린샷 기반이라 1회로 충분 (텍스트 기반이라 2회 필요했던 것) |
+
+**N-4 선행 조건 결정 필요:**
+- **옵션 A:** Kei persona_agent 수정 (ChatRequest에 image 필드 추가, ~50줄)
+ - 장점: Kei의 RAG + 세션 컨텍스트 활용 가능
+ - 단점: persona_agent 코드 수정 필요
+- **옵션 B:** Anthropic API 직접 호출 (persona_agent 수정 없이)
+ - 장점: design_agent 내에서 완결
+ - 단점: Kei의 RAG/세션 없음, 페르소나 프롬프트만 로드
+
+**회귀 위험:** 없음. Stage 5가 기존에 거의 무의미했으므로 (아무것도 안 바뀜), 변경해도 기존 품질이 나빠질 수 없음.
+
+---
+
+### 상호 작용 매트릭스
+
+| | N-1 | N-2 | N-3 | N-4 |
+|--|-----|-----|-----|-----|
+| **N-1** | — | N-2가 N-1에 의존 | 독립 | 독립 |
+| **N-2** | N-1 완료 후 실행 | — | 독립 | 독립 |
+| **N-3** | 독립 | 독립 | — | N-4가 N-3에 의존 |
+| **N-4** | 독립 | 독립 | N-3 완료 후 실행 | — |
+
+**충돌 가능 조합: 없음.** 4개 변경이 모두 파이프라인의 서로 다른 단계를 수정하므로 교차 간섭 없음.
+
+---
+
+## 최종 실행 계획
+
+### 실행 순서 (의존 관계 기반)
+
+```
+① N-3: max-height 래퍼 제거 + _max_chars 편집자 전달
+ (독립, 즉시 실행 가능)
+
+② N-1: 블록 선택 코드 강제
+ (N-3과 독립, ①과 병렬 가능)
+
+③ N-2: 사이드바 섹션 제목
+ (②N-1 완료 후)
+
+④ N-4: 스크린샷 기반 검수
+ (①N-3 완료 후 + persona_agent 수정 또는 직접호출 결정 후)
+```
+
+### 각 항목별 변경 파일 + 예상 규모
+
+| 항목 | 변경 파일 | 신규 코드 | 삭제 코드 | 프롬프트 변경 |
+|------|----------|----------|----------|-------------|
+| **N-3** | renderer.py, content_editor.py, pipeline.py | ~10줄 | ~7줄 | EDITOR_PROMPT에 _max_chars 절대제한 추가 |
+| **N-1** | design_director.py | ~15줄 (후처리 강제) | ~0줄 | STEP_B_PROMPT에서 블록선택 지시 제거 |
+| **N-2** | kei_client.py, design_director.py, renderer.py | ~20줄 | ~0줄 | KEI_PROMPT에 section_title 필드 추가 |
+| **N-4** | slide_measurer.py, kei_client.py, pipeline.py + (persona_agent 4파일) | ~80줄 | ~10줄 | KEI_REVIEW_PROMPT을 이미지 기반으로 변경 |
+
+### 오류 처리 원칙
+
+**Kei API는 필수 인프라다. "실패하면 대체"가 아니라, 실패하면 파이프라인 중단이다.**
+
+| 시나리오 | 처리 | 이유 |
+|----------|------|------|
+| Kei API 미응답 (N-1, N-2, N-3, N-4 공통) | **파이프라인 즉시 중단 + 에러 반환** | Kei는 선택이 아닌 필수. 없으면 돌리면 안 됨 |
+| 편집자(Kei)가 _max_chars 안 지킴 (N-3) | Phase L 루프가 감지 → Kei 편집자 재호출 (최대 3회) | 측정 기반 재시도 |
+| Selenium 스크린샷 실패 (N-4) | Stage 5를 텍스트 기반으로 수행 (현재 방식) | Selenium은 도구. 도구 실패 시 기존 방식 유지 |
+| sidebar label이 높이 초과 유발 (N-2) | label을 고정 30px로 처리, allocate에서 제외 | 본문 블록 공간 유지 |
+
+---
+
+## 파일별 변경 범위 요약
+
+| 파일 | N-1 | N-2 | N-3 | N-4 |
+|------|-----|-----|-----|-----|
+| `design_director.py` | Step B 프롬프트 축소 + 후처리 강제 | sidebar label 삽입 | - | - |
+| `kei_client.py` | - | KEI_PROMPT section_title 추가 | - | 이미지 전달 추가 |
+| `content_editor.py` | - | - | _max_chars 프롬프트 전달 | - |
+| `renderer.py` | - | sidebar label 렌더 | max-height 래퍼 **삭제** | - |
+| `pipeline.py` | - | - | Phase L 루프 _max_chars 축소 | Stage 5 스크린샷 + skip 로직 |
+| `slide_measurer.py` | - | - | - | `capture_slide_screenshot()` 추가 |
+| `space_allocator.py` | - | - | - | - |
+| **Kei persona_agent** | - | - | - | ChatRequest 이미지 확장 (~50줄) |
diff --git a/IMPROVEMENT-PHASE-O.md b/IMPROVEMENT-PHASE-O.md
new file mode 100644
index 0000000..10b8430
--- /dev/null
+++ b/IMPROVEMENT-PHASE-O.md
@@ -0,0 +1,708 @@
+# Phase O: 컨테이너 기반 레이아웃 시스템
+
+> 작성일: 2026-03-27
+> 상태: ✅ 코드 구현 완료 + 후속 정리 완료 (Step B 제거, 죽은 코드 정리, 미해결 3건 해결)
+> 선행 완료: Phase N (catalog 개선, fallback 제거, topic_id 버그 수정)
+
+---
+
+## 핵심 원칙
+
+**"비중이 컨테이너를 확정하고, 컨테이너가 블록을 제약하고, 블록이 콘텐츠를 제약한다."**
+
+```
+Kei 비중 판단 (본심 60%, 배경 20%)
+ ↓
+컨테이너 px 확정 (본심 294px, 배경 98px)
+ ↓
+블록 선택 시 컨테이너 크기 제약 (98px → compact 블록만)
+ ↓
+블록 스펙 확정 (항목 수, 폰트, 패딩, 행 수)
+ ↓
+편집자가 확정 스펙에 맞게 텍스트 작성
+ ↓
+렌더링 (컨테이너 grid로 비중 강제 반영)
+```
+
+---
+
+## 현재 문제 (Phase N 이후에도 남은 것)
+
+### 문제 1: 비중이 시각에 반영 안 됨
+- Kei가 본심 60%, 배경 20%로 판단했지만
+- 실제 렌더링에서 배경이 73%(348px), 본심이 20%(97px)
+- **원인:** 블록이 자연 높이대로 렌더링되고, 비중 기반 컨테이너가 없음
+
+### 문제 2: 블록 선택 시 컨테이너 크기를 모름
+- Kei가 블록을 고를 때 "이 블록이 컨테이너에 들어가는지" 판단 불가
+- 98px 컨테이너에 height_cost=large 블록이 선택됨
+
+### 문제 3: 블록이 컨테이너에 맞게 변형되지 않음
+- 같은 `dark-bullet-list`여도 98px이면 불릿 2개, 294px이면 5개여야 하는데
+- 현재는 블록이 고정 형태로 렌더링됨
+
+### 문제 4: 텍스트 분량이 컨테이너와 무관
+- sidebar 490px인데 용어 정의가 한 줄짜리
+- body 98px인데 문제제기가 3단 구조
+
+---
+
+## 변경 대상 파일 및 역할
+
+| 파일 | 현재 역할 | Phase O 변경 |
+|------|----------|------------|
+| `pipeline.py` | 5단계 오케스트레이션 | 컨테이너 계산을 Step A와 A-2 사이에 삽입 |
+| `space_allocator.py` | _max_chars만 계산 | **컨테이너 스펙 생성기로 확장** (px, 블록 제약, 항목수, 폰트, 글자수) |
+| `design_director.py` | Step A-2에서 블록 선택 | 컨테이너 px를 Kei에게 전달 + height_cost 제약 |
+| `content_editor.py` | _max_chars로 분량 제한 | 블록 스펙(항목수, 글자수/항목)을 프롬프트에 전달 |
+| `renderer.py` | flex-column으로 블록 나열 | **비중 기반 grid row로 컨테이너 생성** |
+| `catalog.yaml` | when/not_for 설명 | 각 블록의 height_cost를 px 범위로 구체화 |
+
+---
+
+## 단계별 상세 설계
+
+### O-1. 컨테이너 스펙 계산 (`space_allocator.py` 확장)
+
+**현재:** `allocate_height_budget()` → `{topic_id: max_height_px}` 딕셔너리만 반환
+
+**변경:** `calculate_container_specs()` → 각 컨테이너의 완전한 스펙을 반환
+
+```python
+def calculate_container_specs(
+ page_structure: dict, # Kei의 비중 판단: {"본심": {"topic_ids": [3], "weight": 0.6}, ...}
+ topics: list[dict], # 각 topic의 purpose, role, layer
+ preset: dict, # 프리셋 zone 정보 (budget_px, width_pct)
+) -> dict[str, ContainerSpec]:
+ """Kei 비중 → 컨테이너 스펙 변환.
+
+ Returns:
+ 역할별 ContainerSpec 딕셔너리. 예:
+ {
+ "본심": ContainerSpec(
+ role="본심",
+ zone="body",
+ topic_ids=[3],
+ weight=0.6,
+ height_px=294, # zone_budget × weight_ratio
+ width_px=716, # slide_width × zone_width_pct × 0.85 (패딩 제외)
+ max_height_cost="xlarge", # 294px이면 xlarge까지 가능
+ block_constraints={
+ "max_items": 7, # 높이 기반 계산
+ "font_size_px": 15.2, # 기본값 유지 가능
+ "padding_px": 20, # 기본값 유지 가능
+ "max_chars_total": 800, # 높이×너비 기반 총 글자수
+ },
+ ),
+ "배경": ContainerSpec(
+ role="배경",
+ zone="body",
+ topic_ids=[1, 2],
+ weight=0.2,
+ height_px=98,
+ width_px=716,
+ max_height_cost="compact", # 98px이면 compact만
+ block_constraints={
+ "max_items": 3,
+ "font_size_px": 13.0, # 줄여야 함
+ "padding_px": 10, # 줄여야 함
+ "max_chars_total": 200,
+ },
+ ),
+ ...
+ }
+ """
+```
+
+**height_cost → px 매핑:**
+
+현재 catalog.yaml의 height_cost는 문자열(`compact`, `medium`, `large`, `xlarge`)이다.
+이것을 px 범위로 매핑해야 Kei가 블록을 고를 때 컨테이너에 맞는지 판단할 수 있다.
+
+```python
+HEIGHT_COST_PX_RANGE = {
+ "compact": (30, 80), # 30~80px
+ "medium": (80, 200), # 80~200px
+ "large": (200, 350), # 200~350px
+ "xlarge": (350, 500), # 350~500px
+}
+```
+
+**컨테이너 높이 → 허용 height_cost 결정:**
+```python
+def max_allowed_height_cost(container_height_px: int) -> str:
+ """컨테이너 높이에서 허용되는 최대 height_cost를 결정."""
+ if container_height_px >= 350:
+ return "xlarge"
+ elif container_height_px >= 200:
+ return "large"
+ elif container_height_px >= 80:
+ return "medium"
+ else:
+ return "compact"
+```
+
+**블록 내부 제약 계산:**
+```python
+def calculate_block_constraints(
+ height_px: int,
+ width_px: int,
+ topic_count: int, # 이 컨테이너에 들어가는 topic 수
+ font_size_px: float,
+ line_height: float,
+ padding_px: int,
+) -> dict:
+ """컨테이너 크기에서 블록 내부 제약을 수학적으로 계산."""
+ # 각 topic에 할당되는 높이
+ per_topic_height = (height_px - padding_px * 2) / topic_count
+
+ # 줄 수
+ line_height_px = font_size_px * line_height
+ max_lines = int(per_topic_height / line_height_px)
+
+ # 줄당 글자 수
+ chars_per_line = int((width_px - padding_px * 2) / (font_size_px * 0.95))
+
+ # 불릿/항목 수 (한 항목 = 약 2줄)
+ max_items = max(1, max_lines // 2)
+
+ # 총 글자 수
+ max_chars_total = max_lines * chars_per_line
+
+ return {
+ "max_lines": max_lines,
+ "max_items": max_items,
+ "chars_per_line": chars_per_line,
+ "max_chars_total": max_chars_total,
+ "max_chars_per_item": max(20, max_chars_total // max(1, max_items)),
+ }
+```
+
+**폰트/패딩 조정 기준:**
+
+| 컨테이너 높이 | 폰트 크기 | 패딩 | line-height |
+|-------------|---------|------|-----------|
+| ≥300px | 15.2px (기본) | 20px (기본) | 1.7 (기본) |
+| 150~299px | 14px | 14px | 1.6 |
+| 80~149px | 13px | 10px | 1.5 |
+| <80px | 12px | 8px | 1.4 |
+
+---
+
+### O-2. 블록 선택에 컨테이너 제약 전달 (`design_director.py`)
+
+**현재:** `_opus_block_recommendation()`이 Kei에게 블록 후보 + 꼭지 목록을 보냄. 컨테이너 크기 정보 없음.
+
+**변경:** 컨테이너 스펙을 Kei에게 함께 전달.
+
+```python
+# _opus_block_recommendation 프롬프트에 추가할 내용
+
+container_text = "\n".join(
+ f"- 꼭지 {tid}: 컨테이너 {spec.height_px}px × {spec.width_px}px, "
+ f"허용 height_cost: {spec.max_height_cost} 이하, "
+ f"최대 항목 수: {spec.block_constraints['max_items']}"
+ for role, spec in container_specs.items()
+ for tid in spec.topic_ids
+)
+
+prompt += (
+ f"\n\n## 컨테이너 제약 (반드시 준수)\n"
+ f"각 꼭지의 블록은 아래 컨테이너 안에 들어가야 한다.\n"
+ f"height_cost가 컨테이너보다 크면 선택 금지.\n\n"
+ f"{container_text}\n"
+)
+```
+
+**코드 레벨 검증 (Kei 응답 후):**
+```python
+# Kei가 선택한 블록의 height_cost가 컨테이너보다 큰지 검증
+for rec in kei_recommendations:
+ tid = rec.get("topic_id") or rec.get("id")
+ block_type = rec.get("block_type", "")
+
+ # catalog에서 height_cost 조회
+ block_height_cost = catalog_map.get(block_type, {}).get("height_cost", "medium")
+
+ # 컨테이너의 max_height_cost 조회
+ container_spec = find_container_for_topic(tid, container_specs)
+ allowed = container_spec.max_height_cost
+
+ # 제약 위반 체크
+ if HEIGHT_COST_ORDER[block_height_cost] > HEIGHT_COST_ORDER[allowed]:
+ logger.warning(
+ f"[O-2 검증] 꼭지 {tid}: {block_type}({block_height_cost})이 "
+ f"컨테이너({container_spec.height_px}px, {allowed} 이하)에 안 맞음"
+ )
+ # 위반 시 → Kei에게 재선택 요청 (컨테이너 제약 명시)
+```
+
+---
+
+### O-3. 블록 스펙 확정 단계 (신규)
+
+**현재:** 없음. 블록이 선택되면 바로 편집자에게 전달.
+
+**변경:** Step A-2 후, Step 3 전에 **블록 스펙 확정** 단계 삽입.
+
+이 단계는 **코드(결정론적)** — AI 호출 없음.
+
+```python
+def finalize_block_specs(
+ blocks: list[dict], # Step A-2에서 확정된 블록 목록
+ container_specs: dict, # O-1에서 계산된 컨테이너 스펙
+ catalog: dict, # catalog.yaml 데이터
+) -> list[dict]:
+ """각 블록의 내부 스펙을 컨테이너 크기에 맞게 확정한다.
+
+ 확정 항목:
+ - _container_height_px: 이 블록이 쓸 수 있는 높이
+ - _container_width_px: 이 블록이 쓸 수 있는 너비
+ - _max_items: 최대 항목/불릿/행 수
+ - _max_chars_per_item: 항목당 최대 글자 수
+ - _max_chars_total: 총 최대 글자 수
+ - _font_size_px: 이 컨테이너에서의 폰트 크기
+ - _padding_px: 이 컨테이너에서의 패딩
+ - _line_height: 이 컨테이너에서의 줄간격
+ """
+ for block in blocks:
+ tid = block.get("topic_id")
+ spec = find_container_for_topic(tid, container_specs)
+ if not spec:
+ continue
+
+ block_type = block.get("type", "")
+ catalog_info = catalog.get(block_type, {})
+
+ # 이 블록이 쓸 수 있는 높이 (같은 컨테이너 안의 다른 블록과 분배)
+ siblings_in_container = [b for b in blocks if find_container_for_topic(b.get("topic_id"), container_specs) == spec]
+ per_block_height = spec.height_px // len(siblings_in_container)
+
+ # 폰트/패딩 결정 (컨테이너 크기 기반)
+ font_size, padding, line_h = determine_typography(per_block_height)
+
+ # 블록별 항목 수 계산
+ constraints = calculate_block_constraints(
+ per_block_height, spec.width_px,
+ topic_count=1, # 이 블록 1개
+ font_size_px=font_size,
+ line_height=line_h,
+ padding_px=padding,
+ )
+
+ # 블록 타입별 세부 조정
+ schema = catalog_info.get("schema", {})
+ if block_type in ("dark-bullet-list",):
+ # 불릿 블록: max_items = 불릿 수
+ block["_max_items"] = min(constraints["max_items"], int(schema.get("max_bullets", {}).get("body", 5)))
+ block["_max_chars_per_item"] = constraints["max_chars_per_item"]
+ elif block_type in ("card-numbered", "card-icon-desc"):
+ # 카드 블록: max_items = 카드 수
+ block["_max_items"] = constraints["max_items"]
+ block["_max_chars_per_item"] = constraints["max_chars_per_item"]
+ elif block_type in ("compare-2col-split", "compare-3col-badge", "table-simple-striped"):
+ # 표 블록: max_items = 행 수
+ block["_max_items"] = constraints["max_items"]
+ block["_max_chars_per_item"] = constraints["max_chars_per_item"]
+ elif block_type in ("comparison-2col",):
+ # 비교 블록: 좌우 각각의 글자 수
+ block["_max_chars_per_item"] = constraints["max_chars_total"] // 2
+ elif block_type in ("banner-gradient",):
+ # 배너: 한 줄
+ block["_max_chars_total"] = constraints["chars_per_line"]
+ else:
+ block["_max_chars_total"] = constraints["max_chars_total"]
+
+ # 공통
+ block["_container_height_px"] = per_block_height
+ block["_container_width_px"] = spec.width_px
+ block["_font_size_px"] = font_size
+ block["_padding_px"] = padding
+ block["_line_height"] = line_h
+ block["_max_chars_total"] = constraints["max_chars_total"]
+
+ return blocks
+```
+
+**typography 결정 함수:**
+```python
+def determine_typography(height_px: int) -> tuple[float, int, float]:
+ """컨테이너 높이에 따른 폰트/패딩/줄간격 결정."""
+ if height_px >= 300:
+ return (15.2, 20, 1.7) # 기본
+ elif height_px >= 150:
+ return (14.0, 14, 1.6) # 약간 축소
+ elif height_px >= 80:
+ return (13.0, 10, 1.5) # 축소
+ else:
+ return (12.0, 8, 1.4) # 최소
+```
+
+---
+
+### O-4. 편집자 프롬프트에 블록 스펙 전달 (`content_editor.py`)
+
+**현재:** `_max_chars`만 전달. 항목 수, 항목당 글자 수, 폰트 크기 정보 없음.
+
+**변경:** O-3에서 확정된 모든 스펙을 편집자에게 전달.
+
+```python
+# fill_content()에서 각 블록의 스펙을 프롬프트에 구체적으로 명시
+
+for i, block in enumerate(blocks):
+ req_text = (
+ f"블록 {i+1} ({block_type}, 영역: {block.get('area')}):\n"
+ f" 목적(purpose): {block.get('purpose')}\n"
+ f" 필수 슬롯: {slots.get('required', [])}\n"
+ )
+
+ # O-4: 블록 스펙 (컨테이너 기반)
+ container_h = block.get("_container_height_px")
+ if container_h:
+ max_items = block.get("_max_items", "제한 없음")
+ max_chars_item = block.get("_max_chars_per_item", "제한 없음")
+ max_chars_total = block.get("_max_chars_total", "제한 없음")
+ font_size = block.get("_font_size_px", 15.2)
+
+ req_text += (
+ f"\n ★ 컨테이너 제약 (절대 준수):\n"
+ f" - 컨테이너 높이: {container_h}px\n"
+ f" - 최대 항목 수: {max_items}개\n"
+ f" - 항목당 최대 글자 수: {max_chars_item}자\n"
+ f" - 총 최대 글자 수: {max_chars_total}자\n"
+ f" - 폰트 크기: {font_size}px\n"
+ f" 이 제약을 넘기면 컨테이너 밖으로 넘친다. 반드시 지켜라.\n"
+ )
+```
+
+**sidebar 용어 정의 예시:**
+```
+블록 5 (card-numbered, 영역: sidebar):
+ 목적(purpose): 용어정의
+ ★ 컨테이너 제약:
+ - 컨테이너 높이: 450px (sidebar 전체)
+ - 최대 항목 수: 3개
+ - 항목당 최대 글자 수: 120자 ← 출처까지 넣을 수 있는 여유
+ - 폰트 크기: 13px
+```
+
+**body 배경(98px) 예시:**
+```
+블록 2 (dark-bullet-list, 영역: body):
+ 목적(purpose): 근거사례
+ ★ 컨테이너 제약:
+ - 컨테이너 높이: 49px (배경 98px / 2 topics)
+ - 최대 항목 수: 2개
+ - 항목당 최대 글자 수: 40자 ← 간결하게
+ - 폰트 크기: 12px
+```
+
+---
+
+### O-5. 렌더러에서 비중 기반 grid row 생성 (`renderer.py`)
+
+**현재:** `_group_blocks_by_area()`가 같은 area 블록을 flex-column으로 나열. 높이 비율 없음.
+
+**변경:** body zone 안에 역할(본심/배경/결론)별 grid row를 생성하고, 각 row의 높이를 비중 기반으로 확정.
+
+```python
+def _group_blocks_by_area_with_containers(
+ blocks: list[dict[str, Any]],
+ container_specs: dict | None = None,
+) -> list[dict[str, Any]]:
+ """같은 area의 블록들을 비중 기반 컨테이너로 그룹핑한다.
+
+ container_specs가 있으면:
+ - body zone 안에서 역할별 컨테이너 div를 생성
+ - 각 컨테이너의 height를 비중 기반 px로 고정
+ - 블록은 해당 컨테이너 안에 배치
+
+ container_specs가 없으면:
+ - 기존 flex-column 방식 (호환)
+ """
+ grouped = OrderedDict()
+ for block in blocks:
+ area = block["area"]
+ if area not in grouped:
+ grouped[area] = {"area": area, "blocks": []}
+ grouped[area]["blocks"].append(block)
+
+ result = []
+ for area, data in grouped.items():
+ block_list = data["blocks"]
+
+ if container_specs and area == "body":
+ # 비중 기반 컨테이너 생성
+ # container_specs에서 이 area의 역할별 높이를 가져옴
+ container_htmls = []
+
+ # 역할 순서: 배경 → 본심 → (결론은 footer)
+ role_order = ["배경", "본심"]
+
+ for role in role_order:
+ spec = container_specs.get(role)
+ if not spec or spec.zone != area:
+ continue
+
+ # 이 역할에 해당하는 블록들
+ role_blocks = [
+ b for b in block_list
+ if b.get("_topic_id_role") == role or b.get("topic_id") in spec.topic_ids
+ ]
+
+ if not role_blocks:
+ continue
+
+ inner_html = "\n".join(b["html"] for b in role_blocks)
+
+ # 컨테이너 div: 높이 고정 + overflow visible (측정용)
+ font_size = spec.block_constraints.get("font_size_px", 15.2)
+ padding = spec.block_constraints.get("padding_px", 20)
+
+ container_htmls.append(
+ f'
\n'
+ f'{inner_html}\n
'
+ )
+
+ html = "\n".join(container_htmls)
+
+ elif len(block_list) == 1:
+ html = block_list[0]["html"]
+ else:
+ inner = "\n".join(b["html"] for b in block_list)
+ html = (
+ f'
\n'
+ f'{inner}\n
'
+ )
+
+ result.append({"area": area, "html": html})
+
+ return result
+```
+
+**CSS 구조 (렌더링 결과):**
+```html
+
+
+
+
+
+
+
+
+```
+
+---
+
+### O-6. 파이프라인 흐름 변경 (`pipeline.py`)
+
+**현재 흐름:**
+```
+1A(Kei 꼭지) → 1B(컨셉) → A-2(블록선택) → B(zone배치) → 공간할당 → 3(편집) → 4(CSS+렌더) → 측정 → 5(검수)
+```
+
+**변경 후:**
+```
+1A(Kei 꼭지 + 비중)
+ ↓
+1B(Kei 컨셉)
+ ↓
+★ 컨테이너 스펙 계산 (O-1, 코드/결정론적)
+ ↓
+A-2(Kei 블록선택 — 컨테이너 제약 전달) (O-2)
+ ↓
+B(Sonnet zone + char_guide)
+ ↓
+★ 블록 스펙 확정 (O-3, 코드/결정론적)
+ ↓
+3(Kei 편집 — 블록 스펙 전달) (O-4)
+ ↓
+4(렌더링 — 컨테이너 grid) (O-5)
+ ↓
+측정(Selenium)
+ ↓
+5(Kei 검수)
+```
+
+**pipeline.py 변경 위치:**
+
+```python
+# 현재 코드 위치: pipeline.py 105행 부근 (2단계 시작 전)
+
+# ★ O-1: 컨테이너 스펙 계산 (1B 완료 후, Step A-2 전)
+yield {"event": "progress", "data": "1.8/5 컨테이너 스펙 계산 중..."}
+
+from src.space_allocator import calculate_container_specs
+container_specs = calculate_container_specs(
+ page_structure=analysis.get("page_structure", {}),
+ topics=analysis.get("topics", []),
+ preset=preset,
+)
+_save_step(run_dir, "step1c_containers.json", {
+ role: {
+ "height_px": spec.height_px,
+ "width_px": spec.width_px,
+ "max_height_cost": spec.max_height_cost,
+ "topic_ids": spec.topic_ids,
+ "block_constraints": spec.block_constraints,
+ }
+ for role, spec in container_specs.items()
+})
+
+# 2단계: Step A-2에 container_specs 전달
+layout_concept = await create_layout_concept(content, analysis, container_specs=container_specs)
+
+# ★ O-3: 블록 스펙 확정 (Step B 후, Step 3 전)
+from src.space_allocator import finalize_block_specs
+for page in layout_concept.get("pages", []):
+ finalize_block_specs(page.get("blocks", []), container_specs, catalog)
+_save_step(run_dir, "step2c_block_specs.json", {
+ "blocks": [
+ {
+ "type": b.get("type"), "topic_id": b.get("topic_id"),
+ "_container_height_px": b.get("_container_height_px"),
+ "_max_items": b.get("_max_items"),
+ "_max_chars_per_item": b.get("_max_chars_per_item"),
+ "_max_chars_total": b.get("_max_chars_total"),
+ "_font_size_px": b.get("_font_size_px"),
+ }
+ for p in layout_concept.get("pages", [])
+ for b in p.get("blocks", [])
+ ]
+})
+
+# 3단계: 편집자에게 블록 스펙이 전달됨 (O-4는 content_editor.py에서 자동 적용)
+```
+
+---
+
+### O-7. 중간 산출물 추가 (리포트 반영)
+
+**새로 추가되는 중간 산출물:**
+
+| 파일 | 단계 | 내용 |
+|------|------|------|
+| `step1c_containers.json` | O-1 | 역할별 컨테이너 스펙 (height_px, width_px, max_height_cost, block_constraints) |
+| `step2c_block_specs.json` | O-3 | 각 블록의 확정 스펙 (_container_height_px, _max_items, _font_size_px 등) |
+
+`generate_run_report.py`에 이 2개 단계를 추가한다.
+
+---
+
+## 실행 순서
+
+```
+O-1: space_allocator.py 확장 (ContainerSpec + calculate_container_specs + calculate_block_constraints + determine_typography)
+ ↓
+O-2: design_director.py 변경 (컨테이너 제약을 Kei에게 전달 + 코드 레벨 height_cost 검증)
+ ↓
+O-3: space_allocator.py 추가 (finalize_block_specs)
+ ↓
+O-4: content_editor.py 변경 (블록 스펙을 편집자 프롬프트에 전달)
+ ↓
+O-5: renderer.py 변경 (비중 기반 grid row 컨테이너 생성)
+ ↓
+O-6: pipeline.py 변경 (새 단계 삽입 + 중간 산출물 저장)
+ ↓
+O-7: generate_run_report.py 확장 (새 중간 산출물 표시)
+```
+
+**의존 관계:**
+- O-1이 먼저 (나머지 모두 O-1의 ContainerSpec에 의존)
+- O-2, O-3은 O-1 완료 후
+- O-4는 O-3 완료 후
+- O-5는 O-1 완료 후 (O-3과 병렬 가능)
+- O-6은 O-1~O-5 전부 완료 후
+- O-7은 O-6 완료 후
+
+---
+
+## 검증 기준
+
+이 Phase가 완료되면 아래가 반드시 성립해야 한다:
+
+1. **비중 = 시각 비율**: Kei가 본심 60%로 판단하면, 실제 렌더링에서 body zone의 60%를 본심 블록이 차지한다
+2. **컨테이너 밖으로 안 넘침**: 각 블록이 자기 컨테이너 높이 안에 들어간다 (overflow:visible이므로 넘치면 Selenium이 감지)
+3. **블록 크기 적합**: 98px 컨테이너에 height_cost=large 블록이 선택되지 않는다
+4. **텍스트 분량 적합**: 490px sidebar에서 용어 정의가 출처까지 포함하고, 98px 배경에서 문제제기가 간결하다
+5. **중간 산출물 확인 가능**: report.html에서 컨테이너 스펙과 블록 스펙을 단계별로 확인할 수 있다
+
+---
+
+## 기술 조사 결과 반영
+
+### 적용하는 것
+- **fonttools** — `calculate_block_constraints()`에서 Pretendard 한글 실측 폭 사용. 하드코딩 `14.0px` 대체. 한글은 uniform-width이므로 정확.
+- **CSS Grid 고정 행** — `grid-template-rows: 98px 294px` 형태로 컨테이너 높이 확정. W3C 표준, 모든 브라우저 지원.
+- **`overflow: visible` + `scrollHeight`** — 컨테이너 높이 고정 + overflow visible → Selenium이 정확히 감지. CSSOM View 스펙 준수.
+
+### 적용하지 않는 것
+- **CSS Container Queries** — 38개 블록 템플릿 전부에 `@container` 규칙 추가 필요. Phase O의 핵심 목표(컨테이너 비중 반영)와 무관한 별도 작업. 필요 시 별도 Phase로.
+- **Playwright** — Selenium으로 이미 작동 중. 성능 문제 체감 시 전환.
+- **PPTAgent 방식 (절대 좌표)** — 우리는 콘텐츠마다 비중이 동적으로 변하므로 절대 좌표 방식 부적합.
+
+### 조사에서 확인된 사실
+- 기존 도구(Slidev, Marp, reveal.js, PPTAgent) 중 비중 기반 컨테이너 시스템을 쓰는 것은 없음. 우리가 직접 구현.
+- PPTAgent의 `suggested_characters` 개념은 우리 `_max_chars`와 유사하지만, 원본 PPTX 고정값 vs 우리는 동적 계산.
+
+---
+
+## 기존 코드 충돌 해결 (6건)
+
+Phase O 적용 시 기존 코드와 충돌하는 지점과 해결 방법.
+
+### 충돌 1: `_max_height_px` vs `_container_height_px`
+- **현재:** pipeline.py:188에서 `block["_max_height_px"]` 설정
+- **해결:** pipeline.py 155~198행(Phase M 공간 할당) 전체를 O-1 `calculate_container_specs()`로 교체
+
+### 충돌 2: `allocate_height_budget()` vs `calculate_container_specs()`
+- **현재:** pipeline.py:179에서 `allocate_height_budget()` 호출
+- **해결:** 호출부 교체. `allocate_height_budget()` 함수는 제거하지 않고 `calculate_container_specs()` 내부에서 재사용 가능.
+
+### 충돌 3: `_max_chars` 단일값 vs `_max_items` + `_max_chars_per_item`
+- **현재:** content_editor.py:121에서 `block.get("_max_chars")` 체크
+- **해결:** N-3에서 추가한 `_max_chars` 프롬프트 코드를 O-4 블록 스펙으로 교체
+
+### 충돌 4: Selenium 측정 스크립트가 container div 못 찾음
+- **현재:** slide_measurer.py:36에서 `[class*="area-"]`만 검색
+- **해결:** `_MEASURE_SCRIPT`에 `.container-*` 셀렉터 추가. container div의 overflow도 감지.
+
+### 충돌 5: Phase L 피드백 루프 필드명
+- **현재:** pipeline.py:276에서 `block.get("_max_chars", 400)` 축소
+- **해결:** `_max_chars_total` 또는 `_max_items` 축소로 변경
+
+### 충돌 6: fonttools 의존성
+- **현재:** pyproject.toml에 fonttools 없음, Pretendard .ttf 로컬 없음
+- **해결:** `pip install fonttools` + Pretendard .ttf 다운로드 (CDN에서)
+
+**원칙:** 모든 충돌은 "기존 코드를 Phase O 코드로 교체"하는 형태. 병존이 아닌 대체. 회귀 없음.
+
+---
+
+## 변경하지 않는 것
+
+- catalog.yaml: Phase N에서 이미 개선 완료. 추가 수정 불필요.
+- kei_client.py: 프롬프트 변경 없음. Kei는 이미 비중을 잘 판단하고 있다.
+- slide_measurer.py: 측정 로직 기본 구조 변경 없음. container 셀렉터만 추가.
+- Kei persona_agent: 수정 없음.
diff --git a/IMPROVEMENT.md b/IMPROVEMENT.md
index a481284..4bfb2f0 100644
--- a/IMPROVEMENT.md
+++ b/IMPROVEMENT.md
@@ -375,6 +375,161 @@ CLAUDE.md 요구사항 전수검토 결과 발견된 미구현/부분구현/위
---
+## Phase J: 블록 선택 권한 구조 재정의 + 최종 검토 Kei 전환 (7개) ✅ 완료
+
+> **실행 상세:** [IMPROVEMENT-PHASE-J.md](IMPROVEMENT-PHASE-J.md)
+> Phase I 완료 후 결과물 3회 비교에서 확인. Sonnet(팀장)이 Opus(실장) 추천을 엎고, 자기가 만든 문제를 자기가 검토하는 구조적 문제.
+
+### Phase J-A: 팀장 권한 제한 + 가이드 수정 (5개)
+- J-1: STEP_B_PROMPT "Opus 추천 존중" 규칙 강화 — "참고" → "기본 사용, 변경 금지"
+- J-2: section-header-bar body 사용 금지 — BODY_FORBIDDEN_MAP에 추가 (삭제 처리)
+- J-3a: purpose 가이드 수정 — 용어정의/근거사례에서 card-icon-desc 제거 → card-numbered
+- J-3b: catalog.yaml 수정 — "용어 정의 → card-icon-desc" → "card-numbered"
+- J-6: sidebar 카드 1열 강제 — 템플릿 column_override + design_director 주입
+
+### Phase J-B: 편집자 강화 (1개)
+- J-4: source 슬롯 금지 규칙 — EDITOR_PROMPT에 출처 규칙 추가 (Kei 편집자 경유)
+
+### Phase J-C: 최종 검토 Kei 전환 (1개)
+- J-7: Stage 5 _review_balance() → Kei API 호출로 전환 — KEI_REVIEW_PROMPT + call_kei_final_review() 신규
+
+---
+
+## Phase K: communicative role 기반 시각적 위계 + 콘텐츠 시퀀싱 (8개)
+
+> **실행 상세:** [IMPROVEMENT-PHASE-K.md](IMPROVEMENT-PHASE-K.md)
+> Phase J 이후에도 결과물 품질 미개선. purpose를 분류하고도 시각적 결과에 반영하지 않은 것이 근본 원인.
+> 사용자 반복 요청(콘텐츠 구조 흐름)을 이번에 전부 반영.
+
+### K-Step 1: 콘텐츠 설계 (가장 중요)
+- K-1: purpose → 시각적 위계 매핑 (핵심전달=주인공, 문제제기=compact)
+- K-2: purpose 기반 인지 흐름 순서 원칙 (하드코딩 아닌 원칙)
+- K-4: purpose별 분량 제약 (문제제기 max 100자, 핵심전달 200-400자 등)
+
+### K-Step 2: 블록 선택 정확성
+- K-3: purpose별 허용/금지 블록 매핑
+- K-6: sidebar 시각적 무게 조절
+- K-8: 비교 블록 맥락 안내
+
+### K-Step 3: 코드 + 검수
+- K-5: column_override 보존 (content_editor.py)
+- K-7: Kei 검수에 구조 흐름 검증 추가
+
+---
+
+## Phase K-1: 파이프라인 스텝별 중간 산출물 로컬 저장
+
+> **실행 상세:** [IMPROVEMENT-PHASE-K1.md](IMPROVEMENT-PHASE-K1.md)
+> 각 스텝에서 뭘 결정했고 왜 그렇게 했는지를 파일로 저장. 사용자가 확인하고 피드백 가능.
+
+- `data/runs/{timestamp}/` 폴더에 step별 JSON + HTML 저장
+- step1 (Kei 분석) → step2 (블록 매핑) → step3 (텍스트) → step4 (렌더링) → step5 (검수) → final
+
+---
+
+## Phase L: 렌더링 측정 에이전트 + Purpose 기반 공간 할당 + 수학적 조정 (11건)
+
+> **실행 상세:** [IMPROVEMENT-PHASE-L.md](IMPROVEMENT-PHASE-L.md)
+> Phase I~K에서 미충족 7건 + 부분충족 4건의 근본 원인: 실제 렌더링 px 측정 없음.
+> LLM 추정이 아닌 코드 계산 + 브라우저 측정으로 전환.
+
+### L-Step 1: 공간 할당 엔진
+- PURPOSE_WEIGHT 비율 할당 + allocate_height_budget() 함수
+- calculate_trim_chars() 수학적 글자 수 계산
+
+### L-Step 2: 렌더링 측정 에이전트
+- measure_rendered_heights() — Selenium headless
+- 각 zone/block의 scrollHeight, clientHeight, overflow 정확 측정
+
+### L-Step 3: CSS max-height 제약
+- purpose별 할당 높이를 CSS max-height로 적용
+- 물리적으로 넘치지 않게 구조적 보장
+
+### L-Step 4: 피드백 루프
+- 측정 → 초과 시 수학적 축약량 계산 → 편집자 재호출 → 재측정
+- Kei 검수에 실제 px 수치 전달 → 근거 있는 검수
+
+---
+
+## Phase M: 비중 시스템 + 역할-블록 매핑 + 블록 안전성 + 원본 보존 (9건)
+
+> **실행 상세:** [IMPROVEMENT-PHASE-M.md](IMPROVEMENT-PHASE-M.md)
+> P-1~P-9 문제점 전수 진단. 비중 시스템(Kei 판단, 하드코딩 아님) 기반 전면 재설계.
+
+### M-Step 1: [긴급] Kei 비중 시스템 (P-1 + P-2 + P-4)
+- Kei가 콘텐츠마다 본심/배경/첨부/결론 + weight 판단
+- PURPOSE_WEIGHT 하드코딩 제거 → Kei 출력 weight 사용
+- weight → px 변환 → 블록 크기/배치 자동 결정
+
+### M-Step 2: [중요] 역할-블록 매핑 (P-3)
+- 역할 × 콘텐츠 성격 → 블록 결정 매트릭스
+
+---
+
+## Phase N: 4대 핵심 문제 해결 ✅ 완료
+
+> **실행 상세:** [IMPROVEMENT-PHASE-N.md](IMPROVEMENT-PHASE-N.md)
+> catalog 개선, fallback 전면 제거, topic_id 버그 수정, 무한 재시도 체계.
+
+- N-1: 블록 선택 코드 레벨 강제 — Kei 확정 블록을 Sonnet이 변경 불가 + topic_id/id 양쪽 체크
+- N-2: 사이드바 섹션 제목 — Kei가 section_title 출력 + divider-text 자동 삽입
+- N-3: max-height CSS 래퍼 제거 — 콘텐츠는 _max_chars로 사전 조절, CSS로 사후 자르기 금지
+- N-4: Stage 5 스크린샷 검수 — Selenium 스크린샷 → Opus 멀티모달로 실제 렌더링 보고 검수
+- **Kei API 무한 재시도** — 모든 Kei API 호출을 성공할 때까지 무한 재시도. fallback/기본값/rule-based 대체 전면 제거
+- **catalog.yaml 전면 개선** — 38개 블록의 when/not_for/purpose_fit 재작성 + FAISS 인덱스 재빌드
+- **삭제:** manual_classify(), _apply_defaults(), _downgrade_fallback(), PURPOSE_FALLBACK 대체용 코드
+
+---
+
+## Phase O: 컨테이너 기반 레이아웃 시스템 🟡 진행 중
+
+> **실행 상세:** [IMPROVEMENT-PHASE-O.md](IMPROVEMENT-PHASE-O.md)
+> Phase N 완료 후 여전히 비중이 시각에 반영 안 되는 근본 문제 해결.
+
+**핵심 원칙:** "비중이 컨테이너를 확정 → 컨테이너가 블록을 제약 → 블록이 콘텐츠를 제약"
+
+- O-1: 컨테이너 스펙 계산 — ✅ 완료 (calculate_container_specs)
+- O-2: 블록 선택에 컨테이너 제약 전달 — ✅ 완료 (Kei 프롬프트 + height_cost 검증)
+- O-3: 블록 스펙 확정 — ✅ 완료 (finalize_block_specs)
+- O-4: 편집자에 블록 스펙 전달 — ✅ 완료 (_container_height_px, _max_items 등)
+- O-5: 렌더러 비중 기반 grid row — ✅ 완료 (container div 생성)
+- O-6: 파이프라인 흐름 변경 — ✅ 완료 (Phase M 코드 교체)
+- O-7: 리포트 확장 — 🟡 미완 (새 중간 산출물 표시 추가 필요)
+- **미세 조정 필요:** 배경 117px / topic 2개 = 58px에 medium 블록 안 맞는 문제
+- **Selenium 측정:** container div 셀렉터 추가 필요
+
+### Step B 제거 + 죽은 코드 정리 ✅ 완료
+
+Phase O에서 Kei(A-2) + 코드가 모든 것을 결정하면서 Step B(Sonnet)가 완전히 무력화됨 → 제거.
+
+**삭제된 코드:**
+- `STEP_B_PROMPT` (~100줄 프롬프트)
+- Step B Sonnet API 호출 코드 (~250줄)
+- `_fallback_layout()` (Step B 실패 시 rule-based)
+- `PURPOSE_FALLBACK` (미등록 블록 대체)
+- `DOWNGRADE_MAP` (블록 다운그레이드)
+- `_downgrade_fallback()` (비상 교체)
+- `_apply_defaults()` (편집 실패 시 기본값)
+- `import anthropic` (design_director.py에서)
+- O-6: 파이프라인 흐름 변경 — 1B 후 컨테이너 계산, Step B 후 블록 스펙 확정
+- O-7: 리포트에 컨테이너/블록 스펙 표시
+
+**기존 코드 교체 (충돌 해결):**
+- `_max_height_px` → `_container_height_px` (pipeline.py 155~198행 교체)
+- `allocate_height_budget()` → `calculate_container_specs()` (호출부 교체)
+- `_max_chars` 단일값 → `_max_items` + `_max_chars_per_item` (content_editor.py 교체)
+- Selenium `_MEASURE_SCRIPT` — container div 셀렉터 추가
+- Phase L 축소 로직 — `_max_chars_total` 축소로 변경
+- fonttools 의존성 + Pretendard .ttf 파일 추가
+
+### M-Step 3: [중요] 블록 안전성 (P-5 + P-6 + P-7 + P-8)
+- Figma 블록 식별, zone 적합성 맵, 글자 수용량, 내부 overflow 감지
+
+### M-Step 4: [보통] 원본 보존 (P-9)
+- source_text 직접 전달, 재작성 금지 강화
+
+---
+
## Phase별 의존 관계
```
diff --git a/PROGRESS.md b/PROGRESS.md
index 32d34c2..d6a4e3a 100644
--- a/PROGRESS.md
+++ b/PROGRESS.md
@@ -1,237 +1,118 @@
# Design Agent — 진행 상황
-## 현재 상태 요약
+## 현재 상태 요약 (2026-03-27 기준)
-| 상태 | 개수 |
+| 상태 | 내용 |
|------|------|
-| done | 23 |
-| in-progress | 0 |
-| todo | 0 |
-| bug-fix | 7 (BF-4~10) |
-| blocked | 0 |
-| **전체** | **30** |
-
-**Phase 2 완료 (2026-03-25):** P2-A~E 전체 done.
+| **완료** | Phase 1~5 기반 구축, Phase I~N 개선, Step B 제거 + 죽은 코드 정리 |
+| **진행 중** | Phase O 컨테이너 시스템 (코드 작성 완료, 미세 조정 필요) |
+| **미해결** | 컨테이너 크기 vs 블록 크기 불일치, Selenium container div 미감지 |
---
-## Phase 1: 기반 구축
+## ✅ 완성된 것
-| 태스크 | 상태 | 담당 | 시작 | 완료 | 메모 |
-|--------|------|------|------|------|------|
-| DA-1: 프로젝트 셋업 | done | - | - | - | pyproject.toml, .env |
-| DA-2: FastAPI 서버 | done | - | - | - | DA-1 이후 |
-| DA-3: 디자인 토큰 + 기본 CSS | done | - | - | - | 독립 작업 가능 |
+### 파이프라인 핵심
+- 5단계 파이프라인 작동 (1A→1B→컨테이너계산→A-2→블록스펙→3→4→측정→5)
+- Kei API 무한 재시도 (모든 Kei 호출. fallback 없음. 제한 없음)
+- Step B(Sonnet 블록 매핑) 제거 — Kei(A-2) + 코드(Phase O)로 대체
+- 죽은 코드 전면 정리 (STEP_B_PROMPT, _fallback_layout, PURPOSE_FALLBACK, DOWNGRADE_MAP, _downgrade_fallback, _apply_defaults, manual_classify)
-## Phase 2: 블록 템플릿 제작
+### 블록/카탈로그
+- 블록 라이브러리 38개 (6 카테고리)
+- catalog.yaml 개선 완료 (when/not_for/purpose_fit)
+- FAISS 인덱스 재빌드 완료 (bge-m3, 38블록)
+- topic_id/id 양쪽 체크 버그 수정
-| 태스크 | 상태 | 담당 | 시작 | 완료 | 메모 |
-|--------|------|------|------|------|------|
-| DA-4: 비교 블록 | done | - | - | - | DA-3 이후 |
-| DA-5: 카드 그리드 | done | - | - | - | DA-3 이후 |
-| DA-6: 관계도 | done | - | - | - | DA-3 이후 |
-| DA-7: 프로세스 | done | - | - | - | DA-3 이후 |
-| DA-8: 강조 인용 | done | - | - | - | DA-3 이후 |
-| DA-9: 결론 바 | done | - | - | - | DA-3 이후 |
-| DA-10: 비교 테이블 | done | - | - | - | DA-3 이후 |
-| DA-11: 슬라이드 조합 렌더러 | done | - | - | - | DA-4~10 이후 |
+### 레이아웃
+- 프리셋 자동 선택 (sidebar-right, two-column, hero-detail, single-column)
+- Kei 비중 시스템 (page_structure weight — 콘텐츠마다 동적)
+- Phase O 컨테이너 스펙 계산 (calculate_container_specs)
+- Phase O 블록 스펙 확정 (finalize_block_specs)
+- 비중 기반 grid row 컨테이너 (renderer.py)
-## Phase 3: AI 파이프라인 연결
+### 측정/검수
+- Phase L Selenium 렌더링 측정 (scrollHeight/clientHeight)
+- Phase N-4 스크린샷 캡처 (slide.screenshot_as_base64)
+- Stage 5 Opus 멀티모달 검수
-| 태스크 | 상태 | 담당 | 시작 | 완료 | 메모 |
-|--------|------|------|------|------|------|
-| DA-12: 1단계 Kei 실장 (꼭지+정보구조+role) | done | - | - | - | Kei API 연동. info_structure + role(flow/reference) |
-| DA-13a: 2단계A 프리셋 선택 (규칙 기반) | todo | - | - | - | reference→sidebar-right, 비교→two-column 등 자동 |
-| DA-13b: 2단계B 블록 매핑 (Sonnet) | todo | - | - | - | 프리셋 CSS 포함 프롬프트. zone별 블록 배정 |
-| DA-13c: 3단계 텍스트 편집자 (Kei 역할) | todo | - | - | - | 의미 우선 편집 + 표 편집 + 자세히보기(요약+상세) |
-| DA-14: 4단계 실무자 + 5단계 재검토 | todo | - | - | - | 디자인 조정 + HTML 조립 + 팀장 균형 재검토 |
+### 인프라
+- 중간 산출물 추적 (data/runs/{timestamp}/)
+- 실행 리포트 생성 (scripts/generate_run_report.py)
+- SSE 스트리밍 유틸 (sse_utils.py)
+- 이미지 크기 측정 + base64 삽입 (image_utils.py)
-## Phase 4: UI + 출력
-
-| 태스크 | 상태 | 담당 | 시작 | 완료 | 메모 |
-|--------|------|------|------|------|------|
-| DA-15: 프론트엔드 | done | - | - | - | DA-14 이후. HTML 다운로드만 |
-| DA-16: 통합 테스트 | done | - | - | - | DA-15 이후 |
+### 버그 수정 완료
+- BF-1: SSE 파싱 실패 → static/index.html 분리 + 정규식
+- BF-2: Jinja2 변수 전달 실패 → get_template().render() 방식
+- BF-3: 한글 깨짐 → UTF-8 BOM 추가
+- BF-4: body 블록 겹침 → _group_blocks_by_area() OrderedDict
+- BF-5: 제목 미표시 → 프리셋 area명 header 통일
+- BF-7: 블록 텍스트 비어있음 → topic_id 매칭 개선
+- BF-8: 컨테이너 예산 기반 배치 → zone별 budget_px
+- BF-9: grid와 Sonnet 역할 분리 → 코드가 grid 강제
+- BF-10: catalog 캐시 갱신 → mtime 체크
---
-## 버그 수정 이력
+## 🟡 진행 중
-### BF-1: 프론트엔드 SSE 파싱 실패 [발견: DA-15 이후]
-- **현상:** 서버는 정상 응답하지만 브라우저에서 결과 미표시. "시작 중..." 고정.
-- **원인:** main.py Python 문자열 안에 HTML/JS를 넣어서 `\n`이 실제 줄바꿈으로 변환 → JS `split('\n\n')` 깨짐. 또한 Windows SSE가 `\r\n\r\n`(CRLF)로 구분.
-- **해결:** static/index.html 별도 파일로 분리. FileResponse로 서빙. SSE split을 `/\r?\n\r?\n/` 정규식으로 변경.
-- **기술:** FileResponse (FastAPI 내장), 추가 의존성 0
-- **충돌 검토:** API 경로와 충돌 없음. 기존 코드 변경 없음. Kei persona 무관.
-- **상태:** done
+### Phase O 컨테이너 시스템
+- **코드 작성 완료:** calculate_container_specs(), finalize_block_specs(), 렌더러 컨테이너 div
+- **문제 확인됨:** 배경 20%=117px에 topic 2개 → 각 58px. callout-warning(122px)이 안 맞음
+- **원인:** height_cost "medium"(80~200px)이 컨테이너 58px보다 큰데 통과됨
+- **필요 조치:** 컨테이너 px가 작을 때 topic당 블록 높이를 더 정밀하게 제약
-### BF-2: 블록 내용이 비어있음 (Jinja2 include 변수 전달 실패) [발견: BF-1 이후]
-- **현상:** 슬라이드 HTML은 생성되지만 모든 블록 텍스트가 비어있음. 레이아웃 구조만 있고 내용 없음.
-- **원인:** renderer.py에서 Jinja2 `include`로 블록 템플릿을 삽입하는데, `include`는 블록별 변수를 개별 전달하지 못함. Sonnet이 채운 data가 템플릿에 도달 안 함.
-- **해결:** `include` 대신 각 블록 템플릿을 `env.get_template().render(**data)`로 개별 렌더링 후 완성된 HTML을 삽입. `render_standalone_block()`이 이미 이 방식으로 동작 중 → 통일.
-- **기술:** Jinja2 `get_template().render()` (내장), 추가 의존성 0
-- **수정 파일:** renderer.py, templates/slide-base.html
-- **충돌 검토:** 블록 템플릿 7개 변경 없음. pipeline.py 호출 시그니처 동일. Kei persona 무관.
-- **상태:** done
+### Phase L 피드백 루프
+- **동작:** 측정 → overflow 감지 → _max_chars_total 축소 → 편집자 재호출
+- **문제:** `_MEASURE_SCRIPT`가 `.area-*`만 검색. Phase O의 `.container-*` div를 못 찾음
+- **필요 조치:** slide_measurer.py에 container div 셀렉터 추가
-### BF-3: 한글 깨짐 (다운로드 HTML 파일) [발견: BF-1 이후]
-- **현상:** 다운로드한 HTML 파일에서 한글이 `ê±´ì¤ì°ì` 같은 깨진 문자로 표시.
-- **원인:** Blob 다운로드 시 UTF-8 BOM 미포함. 일부 에디터/브라우저가 인코딩 자동 감지 실패.
-- **해결:** download() 함수에서 Blob 생성 시 UTF-8 BOM(`'\uFEFF'`) 접두사 추가.
-- **기술:** JavaScript BOM 1줄, 추가 의존성 0
-- **수정 파일:** static/index.html
-- **충돌 검토:** 미리보기(iframe)에 영향 없음. SSE 파싱에 영향 없음.
-- **상태:** done
-
-### BF-4: body 블록 겹침 [발견: 프리셋 도입 후]
-- **현상:** body area에 4개 블록이 겹쳐서 하나만 보임
-- **원인:** renderer가 같은 area에 별도 div 생성 → CSS Grid 겹침
-- **해결:** OrderedDict로 같은 area 그룹핑 → 하나의 div에 flex-column
-- **기술:** Python OrderedDict (내장)
-- **수정 파일:** renderer.py
-- **상태:** 코드 수정 완료, 테스트 필요
-
-### BF-5: 제목 안 보임 [발견: 프리셋 도입 후]
-- **현상:** 슬라이드 제목이 표시 안 됨
-- **원인:** 프리셋 area명 `title` vs slide-base.html `header` 불일치
-- **해결:** 프리셋 4개에서 `title` → `header` 교체
-- **기술:** 문자열 교체
-- **수정 파일:** design_director.py LAYOUT_PRESETS
-- **상태:** sidebar-right 수정 완료, 나머지 3개 확인 필요
-
-### BF-6: sidebar 카드 3열 찢어짐 [발견: sidebar-right 테스트]
-- **현상:** sidebar 35% 너비에 카드 3열 → 각 카드 폭 극히 좁아 찢어짐
-- **원인:** 팀장이 sidebar 공간 고려 없이 배치
-- **해결:** Step B 프롬프트에 sidebar 공간 안내 추가
-- **기술:** 프롬프트 엔지니어링
-- **수정 파일:** design_director.py STEP_B_PROMPT
-- **상태:** 미수정
-
-### BF-7: body 블록 텍스트 비어있음 [발견: 편집자 출력 확인]
-- **현상:** body의 4개 블록 중 1개만 텍스트 있고 3개 비어있음
-- **원인:** content_editor 매칭에서 같은 area 첫 번째만 매칭 (break)
-- **해결:** area + topic_id로 정확 매칭. 편집자 프롬프트에 topic_id 출력 추가
-- **기술:** Python 조건문 수정
-- **수정 파일:** content_editor.py
-- **상태:** 미수정
-
-### BF-8: 컨테이너 예산 기반 블록 배치 [발견: 파이프라인 실행 후 프레임 넘침]
-- **현상:** body에 4개 블록(quote+card+venn+comparison) 쌓아서 총 ~810px → 490px 예산 초과 → 잘림
-- **원인:** 팀장 프롬프트가 콘텐츠 중심 블록 선택 (높이 제약 없음). 큰 SVG(380px)를 다른 블록과 함께 배치
-- **해결:**
- - LAYOUT_PRESETS: zone별 budget_px + width_pct 추가
- - STEP_B_PROMPT: "컨테이너 예산 확인 → 배정 → 블록+높이 계산 → 검증" 4단계 사고
- - catalog.yaml: 블록별 height_cost (compact/medium/large/xlarge) + 높이 참고표
- - base.css: area div에 overflow:hidden + min-height:0 안전망
- - 시각화 블록: flex-shrink + responsive SVG
-- **수정 파일:** design_director.py, catalog.yaml, base.css, venn-diagram.html, circle-gradient.html
-- **상태:** done (2026-03-25)
-- **한계:** 프롬프트만으로는 Sonnet이 grid를 무시하는 문제를 방지 불가 → BF-9 필요
-- **충돌 해소 (2026-03-25):** 다른 에이전트가 구 블록(quote-block, card-grid, comparison)을 BLOCK_SLOTS/defaults에서 의도적 제거. BF-8에서 catalog에 복원했던 것을 다시 제거하여 정합성 확보. 구 블록 → 신규 블록 대체 방향 확정.
-
-### BF-9: grid와 Sonnet의 역할 분리 [발견: 파이프라인 실행 결과 분석]
-- **현상:** Sonnet이 프리셋 grid 대신 자기만의 5행 all-auto grid 생성. zone명도 불일치(main, definitions 등)
-- **원인:** 설계 오류 — Sonnet에게 grid 값을 출력하라고 요구한 것 자체가 잘못. grid는 코드(Step A)가 결정, Sonnet이 건드릴 대상 아님
-- **해결:**
- - Step B 프롬프트: grid 출력 요구 제거, blocks 배열만 출력하도록 변경
- - create_layout_concept(): grid 값은 프리셋에서 직접 가져옴 (Sonnet 출력 무시)
- - Sonnet이 출력한 area명이 프리셋 zone에 없으면 코드에서 자동 매핑
-- **원칙:** 코드가 결정한 것은 코드가 유지한다. Sonnet은 콘텐츠 판단만.
-- **수정 파일:** design_director.py (STEP_B_PROMPT + create_layout_concept)
-- **상태:** done (2026-03-25)
-
-### BF-10: _CATALOG_MAP 캐시 갱신 문제 [발견: 파이프라인 실행 결과 분석]
-- **현상:** relationship 블록이 _legacy CSS 원형으로 렌더링됨 (SVG premium이 아님). catalog.yaml 매핑이 적용 안 됨.
-- **원인:** _CATALOG_MAP이 모듈 레벨 global로 한 번만 로드. 서버가 구 catalog를 캐시.
-- **해결:** 파일 mtime 확인 후 자동 reload, 또는 매 렌더링 시 강제 reload
-- **기술:** Python pathlib stat()
-- **수정 파일:** renderer.py
-- **상태:** done (2026-03-25)
-
-## Phase 5: 블록 라이브러리 확장
-
-| 태스크 | 상태 | 담당 | 시작 | 완료 | 메모 |
-|--------|------|------|------|------|------|
-| DA-17: Figma 에셋 추출 + 블록 템플릿 | done | - | 2026-03-25 | 2026-03-25 | 스크린샷 16장, 에셋 15개+, 신규 블록 6종 |
-| DA-18: 카테고리 폴더 재편 | done | - | 2026-03-25 | 2026-03-25 | 6개 카테고리 + INDEX.md |
-| DA-19: 변형 확장 | done | - | 2026-03-25 | 2026-03-25 | 46개 달성. catalog/BLOCK_SLOTS/INDEX 전체 동기화 완료 |
-
-## Phase 2: 파이프라인 고도화
-
-| 태스크 | 상태 | 담당 | 시작 | 완료 | 메모 |
-|--------|------|------|------|------|------|
-| P2-A: FAISS 블록 검색 | done | - | 2026-03-25 | 2026-03-25 | bge-m3 1024d, 46벡터. block_search.py 신규. director 연동 완료 |
-| P2-B: SVG N개 자동 배치 | done | - | 2026-03-25 | 2026-03-25 | svg_calculator.py 신규. N=2~7 테스트 통과. Phase 1 fallback 유지 |
-| P2-C: Step A Opus+FAISS | done | - | 2026-03-25 | 2026-03-25 | _opus_block_recommendation(). Kei API 경유. Anthropic 직접 0회 |
-| P2-D: 5단계 재검토 강화 | done | - | 2026-03-25 | 2026-03-25 | MAX_REVIEW_ROUNDS=2. expand/shrink/rewrite 3개 action. 다른쪽 구현+루프 추가 |
-| P2-E-1: Pillow 이미지 크기 | done | - | 2026-03-25 | 2026-03-25 | 다른쪽에서 image_utils.py 구현 완료 |
-| P2-E-2: details-block 연결 | done | - | 2026-03-25 | 2026-03-25 | "생략"→details-block 배치. fallback에도 반영 |
-| DA-21: renderer 카테고리 경로 지원 | todo | - | - | - | DA-18 이후 |
-| DA-22: catalog.yaml 경로 업데이트 | todo | - | - | - | DA-21 이후 |
+### BF-6: sidebar 카드 찢어짐
+- Phase J에서 column_override + SIDEBAR_FORBIDDEN_BLOCKS 추가
+- 완전 해결 여부 테스트 필요
---
-## 블로킹 이슈
+## ❌ 미해결 → ✅ 해결됨 (2026-03-27)
-없음
-
----
-
-## DA-17 상세 기록
-
-### Figma 추출 결과
-- **파일:** 바론컨설턴트 홈페이지_기획팀공유 (uw7Z2hZGv9k6ygwrgYaAnF)
-- **접근:** Figma REST API (유료 계정 토큰)
-- **스크린샷:** 16장 (메인 3 + 자세히보기 13)
-- **에셋:** bg_header, card_img x3, compare_box x2, dx_bim_table, circle_label, mountain_viz, image_grid x2 등
-- **노드 분석:** 2-1_01 (건설산업), 2-1_02 (BIM) depth=4 상세 구조
-
-### 신규 블록 템플릿 6종
-| 블록 | 카테고리 | 검증 결과 |
-|------|---------|---------|
-| section-title-with-bg | headers/ | ✅ 렌더링 OK |
-| topic-left-right | headers/ | ✅ 렌더링 OK, 사용자 확인 |
-| compare-pill-pair | visuals/ | ✅ 색상 2차 수정 후 OK |
-| circle-gradient | visuals/ | ✅ 사용자 확인 OK |
-| card-image-3col | cards/ | ✅ 사용자 확인 OK |
-| image-row-2col | media/ | ✅ 렌더링 OK |
-
-### 기존 블록 수정
-| 블록 | 수정 내용 |
+| 항목 | 해결 내용 |
|------|---------|
-| comparison-table → compare-3col-badge | Figma 톤으로 재디자인 (중앙 VS 배지, 좌우 중앙정렬) |
-| conclusion-bar → conclusion-accent-bar | Figma 톤으로 재디자인 (좌측 파란 라인 + 밝은 배경) |
-| compare-box → compare-pill-pair | Figma 톤으로 재디자인 (하늘색 둥근 테두리 + 시안 텍스트) |
-
-### 시각화 방식 검증 이력
-1. **CSS 원형 벤 다이어그램** → 실패 (클로드스러운 플랫 디자인, 20점)
-2. **AntV infographic-cli** → 제한적 (일부 SSR 타임아웃, 관계도 용도 안 맞음)
-3. **AI 이미지(Gemini) + HTML 텍스트 오버레이** → 실패 (이미지 내 원 위치가 매번 달라 텍스트 위치 맞출 수 없음)
-4. **SVG premium (radialGradient + filter + 수학적 좌표 계산)** → **성공! 최종 확정**
- - 텍스트가 SVG 안에 있어 위치 100% 정확
- - 그라데이션/글로우/하이라이트로 Figma 수준 품질
- - N개 원소 자동 배치 (360/N 간격, cos/sin)
-
-### Phase 1 완료 요약 (2026-03-25)
-- 블록 라이브러리: 6개 카테고리, 18개 블록 변형
-- 시각화 방식: SVG premium 확정
-- Figma 에셋: 스크린샷 16장, 에셋 15개+
-- 블록 검증: 독립 렌더링 테스트 전체 통과
-- 노하우: 텍스트=HTML/CSS, 시각화=SVG, 실사=이미지, AI이미지=배경전용
+| 컨테이너 px vs 블록 높이 불일치 | `_max_allowed_height_cost()`를 topic당 높이(per_topic_px)로 판단하도록 수정 |
+| Selenium container div 미감지 | `_MEASURE_SCRIPT`에 `.container-*` 셀렉터 추가 + pipeline.py에서 container overflow 체크 |
+| catalog.yaml schema 글자수 하드코딩 | 37개 필드를 `ref_chars` + `max_lines` + `font_size` 구조로 변환. FAISS 재빌드 완료 |
---
-## 완료된 준비 사항
+## Phase 이력
+
+| Phase | 내용 | 상태 | 비고 |
+|-------|------|------|------|
+| 1~3 | 기반 구축 + 블록 템플릿 + AI 파이프라인 | 완료 | |
+| 4 | UI + 출력 | 완료 | |
+| 5 | 블록 라이브러리 확장 (38개) | 완료 | |
+| A~D | 슬라이드 품질 핵심 | 완료 | 일부 Phase O로 대체 |
+| G | Kei API 통신 정상화 | 완료 | |
+| H | 스토리라인 설계 기반 전환 | 완료 | |
+| I | 전수 정합성 복구 (14건) | 완료 | |
+| J | 블록 선택 권한 재정의 | 완료 | Step B 제거로 일부 무력화 |
+| K | purpose 기반 시각적 위계 | 완료 | |
+| K-1 | 중간 산출물 저장 | 완료 | |
+| L | Selenium 렌더링 측정 | 완료 | container div 감지 미완 |
+| M | Kei 비중 시스템 | 완료 | Phase O로 교체 |
+| N | 4대 핵심 문제 해결 | 완료 | catalog, fallback, topic_id, 무한재시도 |
+| **O** | **컨테이너 기반 레이아웃** | **진행 중** | 코드 완료, 미세 조정 필요 |
+| — | Step B 제거 + 죽은 코드 정리 | 완료 | Phase O 후속 |
+
+---
+
+## 프로젝트 구조
| 항목 | 파일 | 상태 |
|------|------|------|
-| 프로젝트 규칙 | CLAUDE.md | 완료 (블록 라이브러리 구조 반영) |
-| 실행 계획 | PLAN.md | 완료 (Phase 5 추가) |
-| 진행 추적 | PROGRESS.md | 완료 (이 파일) |
-| 기술 조사 | docs/RESEARCH.md | 완료 |
-| Figma 분석 | docs/figma-analysis/DESIGN-ANALYSIS.md | 완료 |
-| Figma 추출 계획 | docs/FIGMA-COMPONENT-EXTRACTION-PLAN.md | 완료 |
-| 블록 라이브러리 | templates/blocks/ (6개 카테고리) | 구축 완료, 변형 확장 중 |
-| 블록 인덱스 | templates/blocks/INDEX.md | 완료 |
-| 블록 카탈로그 | templates/catalog.yaml | 완료 (경로 업데이트 필요) |
-| MCP 설정 | .mcp.json (Framelink Figma MCP) | 완료 |
+| 프로젝트 규칙 | CLAUDE.md | 완료 |
+| 개선 계획 | IMPROVEMENT.md | Phase O까지 반영 |
+| 진행 추적 | PROGRESS.md | 이 파일 (2026-03-27 갱신) |
+| 전체 감사 | CLEANUP-AUDIT.md | 유효/무력화 분류 완료 |
+| Phase별 상세 | IMPROVEMENT-PHASE-{A~O}.md | 각 Phase 기록 |
+| README | README.md | Phase O + Step B 제거 반영 |
diff --git a/README.md b/README.md
index 7a61176..fbc7f4f 100644
--- a/README.md
+++ b/README.md
@@ -1,374 +1,251 @@
# Kei Design Agent
-콘텐츠를 시각적으로 구조화된 슬라이드 HTML로 변환하는 독립 에이전트.
+콘텐츠를 시각적으로 구조화된 슬라이드 HTML(1280×720px, 16:9)로 변환하는 AI 파이프라인.
## 개요
-텍스트/MDX 콘텐츠를 입력하면, AI가 정보 구조를 파악하고 적합한 레이아웃과 블록을 선택하여 깔끔한 1페이지(또는 다중 페이지) 슬라이드를 생성합니다.
+텍스트/MDX 콘텐츠를 입력하면 Kei 실장(Opus)이 정보 구조와 비중을 판단하고, 그 비중대로 컨테이너를 확정하고, 블록을 선택하고, 텍스트를 편집하여 슬라이드를 생성한다.
-## 아키텍처 (5단계 파이프라인)
+**핵심 특징:**
+- 콘텐츠마다 비중이 동적으로 변한다 (본심 60% / 배경 20% 등 — Kei가 매번 판단)
+- 비중이 컨테이너 px를 확정 → 블록과 텍스트가 컨테이너에 맞춰진다
+- Kei API 필수. fallback 없음. 성공할 때까지 무한 재시도.
+
+---
+
+## 파이프라인 (6단계)
```
-텍스트 입력 (+ 이미지 폴더 경로)
+텍스트 입력
↓
-━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
-[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에 포함
-│
+[1단계] Kei 실장 — 꼭지 추출 + 비중 판단 (Kei API / Opus)
↓
-━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
-[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 비상 작동 (기계적 블록 교체)
-│
+[1.5단계] Kei 실장 — 컨셉 구체화 (Kei API / Opus)
↓
-━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
-[3단계] Kei 텍스트 편집자 — 도메인 전문가로서 텍스트 정리
-━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
-│ 사용 AI: Kei API (Opus + RAG + 도메인 지식)
-│ Sonnet fallback 없음 (Kei API만 사용)
-│
-│ - 각 블록의 슬롯에 맞게 텍스트 정리
-│ - 슬롯 의미 설명(slot_desc) 참고하여 정확한 데이터 배치 (I-4, I-5)
-│ - 글자 수 가이드 참고, 내용 의미 우선
-│ - 2.5단계에서 trim 제약이 있으면 반영
-│ - 원본 텍스트 최대 보존, 출처 보존, 개조식, 날조 금지
-│ - detail_target 꼭지: summary + detail 두 버전 작성
-│
+[컨테이너 계산] 비중 → px 확정 (코드, 결정론적)
↓
-━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
-[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에서도 표시)
-│
+[2단계] 블록 확정 + 배치 (Kei API + Sonnet)
↓
-━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
-[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 다운로드
-━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+[3단계] Kei 편집자 — 텍스트 정리 (Kei API / Opus)
+ ↓
+[4단계] 디자인 실무자 — CSS 조정 + HTML 조립 (Sonnet + Jinja2)
+ ↓
+[Phase L] Selenium 렌더링 측정 → 피드백 루프
+ ↓
+[5단계] Kei 실장 — 최종 검수 (스크린샷) (Opus 멀티모달)
+ ↓
+완성 슬라이드 HTML
```
-### 각 단계별 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) | — |
+| 단계 | 담당 | AI | 역할 |
+|------|------|-----|------|
+| **1A** | Kei 실장 | Kei API (Opus) | 핵심 메시지, 꼭지 추출, page_structure(비중), purpose 부여 |
+| **1B** | Kei 실장 | Kei API (Opus) | relation_type, expression_hint, source_data |
+| **컨테이너** | 코드 | — | Kei 비중 → 역할별 컨테이너 px 확정, height_cost 제약, 블록 스펙 |
+| **2 A-2** | Kei 실장 | Kei API (Opus) | 컨테이너 제약 보고 블록 확정 (FAISS 후보 기반) |
+| **2 B** | 디자인 팀장 | Sonnet | zone 배치 + char_guide만 (블록 타입 변경 불가) |
+| **블록 스펙** | 코드 | — | 컨테이너 크기 → 항목수/글자수/폰트/패딩 확정 |
+| **3** | Kei 편집자 | Kei API (Opus) | 텍스트 편집 (컨테이너 제약 준수, 원본 보존) |
+| **4** | 디자인 실무자 | Sonnet | CSS 변수 override + Jinja2 렌더링 |
+| **Phase L** | 코드 | Selenium | 렌더링 측정 → overflow 감지 → 편집자 재호출 |
+| **5** | Kei 실장 | Opus | 스크린샷 보고 최종 검수 (멀티모달) |
+
+### 핵심 원칙
+
+- **비중 → 컨테이너 → 블록 → 콘텐츠** 순서. 비중이 모든 것을 결정
+- **Kei API 필수.** fallback 없음. 기본값 없음. 성공할 때까지 무한 재시도
+- **Sonnet은 zone 배치 + CSS 조정만.** 블록 선택/콘텐츠 판단 금지
+- **블록 선택은 Kei가 확정 → 코드가 강제.** Sonnet이 변경 불가
+- **텍스트가 기준.** 디자인이 텍스트에 맞춤. CSS로 사후 자르기 금지
+
+---
+
+## 컨테이너 시스템 (Phase O)
+
+Kei가 판단한 비중이 시각적 레이아웃에 정확히 반영되는 구조.
+
+```
+슬라이드 1280×720px
+├── header: 제목 (~60px 고정)
+├── body (65%): 490px
+│ ├── 배경 컨테이너: 490 × 20% = 98px ← Kei 비중으로 확정
+│ │ └── 문제제기 + 근거사례 (compact 블록만)
+│ └── 본심 컨테이너: 490 × 60% = 294px ← Kei 비중으로 확정
+│ └── 핵심전달 (large/xlarge 블록 가능)
+├── sidebar (35%): 490px
+│ └── 첨부 컨테이너: 490px 전체
+│ └── 용어 정의 (여유 있게)
+└── footer: 결론 (~60px 고정)
+ └── banner-gradient (핵심 메시지 한 줄)
+```
+
+- 컨테이너 높이(px)가 블록의 height_cost를 제약
+- 컨테이너 크기에서 항목수/글자수/폰트/패딩이 자동 계산
+- 편집자에게 컨테이너 제약이 전달되어 텍스트 분량이 맞춰짐
+
+---
+
+## 개선 이력
+
+| Phase | 내용 | 상태 |
+|-------|------|------|
+| **A~D** | 슬라이드 품질 핵심 (디자인 조정, overflow 방지, 이미지 처리) | 완료 |
+| **G** | Kei API 통신 정상화 (SSE 스트리밍, Sonnet fallback 제거, GPU 분리) | 완료 |
+| **H** | 스토리라인 설계 기반 전환 (core_message, purpose, source_hint) | 완료 |
+| **I** | 전수 정합성 복구 + 넘침 처리 패러다임 전환 (14건) | 완료 |
+| **J** | 블록 선택 권한 구조 재정의 + 최종 검토 Kei 전환 | 완료 |
+| **K** | communicative role 기반 시각적 위계 + purpose별 분량 제약 | 완료 |
+| **K-1** | 파이프라인 스텝별 중간 산출물 로컬 저장 (`data/runs/`) | 완료 |
+| **L** | 렌더링 측정 에이전트 (Selenium headless) + 피드백 루프 | 완료 |
+| **M** | Kei 비중 시스템 (page_structure weight) + 원본 보존 강화 | 완료 |
+| **N** | 4대 핵심 문제 해결 — catalog 개선, fallback 전면 제거, topic_id 버그 수정, 무한 재시도 | 완료 |
+| **O** | 컨테이너 기반 레이아웃 시스템 — 비중→px→블록제약→콘텐츠제약 | **진행 중** |
+
+---
+
+## 중간 산출물
+
+파이프라인 실행마다 `data/runs/{timestamp}/`에 단계별 결과가 저장된다.
+
+| 파일 | 단계 | 내용 |
+|------|------|------|
+| `step1_analysis.json` | 1A | 꼭지 추출, page_structure(비중), core_message |
+| `step1b_concepts.json` | 1B | relation_type, expression_hint, source_data |
+| `step1c_containers.json` | O-1 | 역할별 컨테이너 스펙 (height_px, width_px, max_height_cost) |
+| `step2_layout.json` | 2 | 블록 배치 (area, type, purpose, reason) |
+| `step2c_block_specs.json` | O-3 | 블록별 스펙 (_max_items, _max_chars, _font_size_px) |
+| `step3_filled_blocks.json` | 3 | 텍스트 편집 결과 (data, char_count) |
+| `step4_css_adjustment.json` | 4 | CSS 변수 override |
+| `step4_rendered.html` | 4 | 렌더링된 HTML |
+| `step4_measurement_round*.json` | Phase L | Selenium 측정 (scrollHeight, overflow) |
+| `step5_review_round*.json` | 5 | Kei 검수 결과 |
+| `final.html` | 최종 | 완성 슬라이드 |
+| `report.html` | 리포트 | 전 단계 시각화 리포트 |
+
+리포트 생성: `python scripts/generate_run_report.py`
+
+---
## 블록 라이브러리 (38개)
-```
-templates/blocks/
-├── INDEX.md 전체 인덱스
-├── headers/ (5개) 타이틀, 꼭지 헤더
-│ ├── section-title-with-bg.html 배경 이미지 + 영문/한글
-│ ├── section-header-bar.html 파란 배경 바 + 제목
-│ ├── topic-left-right.html 좌:제목 + 우:설명
-│ ├── topic-center.html 중앙 정렬 제목
-│ └── topic-numbered.html 번호 + 제목 + 설명
-├── cards/ (9개) 카드 계열
-│ ├── card-image-3col.html 이미지 카드 3열
-│ ├── card-dark-overlay.html 다크 오버레이 카드
-│ ├── card-tag-image.html 태그 + 이미지 카드
-│ ├── card-icon-desc.html 아이콘 + 설명 카드
-│ ├── card-compare-3col.html 비교 카드 3열
-│ ├── card-step-vertical.html 세로 단계 카드
-│ ├── card-image-round.html 원형 이미지 카드
-│ ├── card-stat-number.html 큰 숫자 KPI 카드
-│ └── card-numbered.html 번호 리스트 카드
-├── tables/ (3개) 비교 테이블
-│ ├── compare-3col-badge.html A|VS배지|B 3단 비교
-│ ├── compare-2col-split.html 좌우 분할 비교
-│ └── table-simple-striped.html 줄무늬 일반 테이블
-├── 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 키워드 원형 나열
-├── emphasis/ (10개) 강조, 인용, 결론
-│ ├── quote-big-mark.html 큰 따옴표 인용
-│ ├── quote-question.html 질문형 강조
-│ ├── comparison-2col.html 2단 비교
-│ ├── banner-gradient.html 그라데이션 배너
-│ ├── dark-bullet-list.html 다크 배경 불릿 리스트
-│ ├── highlight-strip.html 하이라이트 스트립
-│ ├── callout-solution.html 솔루션 콜아웃
-│ ├── callout-warning.html 경고 콜아웃
-│ ├── tab-label-row.html 탭 라벨 행
-│ └── 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 비교
-```
+6개 카테고리, 38개 블록. 각 블록은 `catalog.yaml`에 용도(when), 금지(not_for), purpose_fit이 정의됨.
-## FAISS 블록 검색
+| 카테고리 | 개수 | 용도 |
+|---------|------|------|
+| **headers** | 5 | 타이틀, 꼭지 헤더 |
+| **cards** | 9 | 항목 나열, 카드 그리드 |
+| **tables** | 3 | 비교표, 데이터 테이블 |
+| **visuals** | 6 | SVG 다이어그램, 관계도 |
+| **emphasis** | 10 | 강조, 인용, 결론, 불릿 |
+| **media** | 5 | 이미지/사진 |
-38개 블록 전체를 프롬프트에 넣는 대신, FAISS로 꼭지별 관련 블록만 검색하여 전달합니다.
+FAISS 블록 검색: bge-m3 1024차원 임베딩 → 꼭지별 관련 블록 후보 추출
-```
-꼭지 "A vs B 비교" → FAISS 검색 → comparison-2col, compare-pill-pair, compare-2col-split
-꼭지 "연도별 로드맵" → FAISS 검색 → process-horizontal, flow-arrow-horizontal, card-step-vertical
-```
-
-- 임베딩 모델: BAAI/bge-m3 (1024차원, 한국어 최적화)
-- 인덱스 빌드: `python scripts/build_block_index.py`
-- fallback: 인덱스 없으면 catalog.yaml 전문 전달 (기존 방식)
-
-## 레이아웃 프리셋
-
-| 프리셋 | 조건 | CSS Grid | zone 예산 |
-|--------|------|----------|----------|
-| `sidebar-right` | reference 꼭지 있음 | 65:35 좌우 분할 | body 490px, sidebar 490px |
-| `two-column` | 대등한 비교 | 50:50 균등 | left 490px, right 490px |
-| `hero-detail` | 고강조 1개 + 보조 | hero 영역 + detail | hero 310px, detail 155px |
-| `single-column` | 순차적 flow만 | 1열 | body 490px |
-
-grid는 코드(Step A)가 결정. Sonnet은 blocks만 출력. grid 변경 불가.
+---
## 기술 스택
| 역할 | 도구 |
|------|------|
| 서버 | FastAPI + uvicorn (포트 8001) |
-| AI (1단계 실장) | Kei API (Opus) → fallback: Sonnet |
-| AI (2단계 A-2) | Kei API (Opus) — 블록 추천 |
-| AI (2단계 B) | Anthropic API (Sonnet) — 블록 매핑 |
-| AI (3단계 편집자) | Kei API → fallback: Sonnet |
-| AI (4단계 실무자) | Anthropic API (Sonnet) — CSS 조정 |
-| AI (5단계 재검토) | Anthropic API (Sonnet) — 균형 점검 |
-| 블록 검색 | FAISS + bge-m3 (38개 블록 인덱스) |
-| 템플릿 | Jinja2 (카테고리별 블록 조합) |
-| 렌더링 | CSS Grid + 디자인 토큰 (16:9, 1280×720) |
-| SVG 시각화 | svg_calculator.py (cos/sin 좌표 계산, N개 동적) |
-| 이미지 처리 | Pillow (크기 측정) + base64 인라인 |
-| 폰트 | Pretendard Variable (한국어) |
+| AI (Kei 실장/편집자) | Kei API → Opus (localhost:8000) |
+| AI (디자인 팀장/실무자) | Anthropic API → Sonnet |
+| AI (최종 검수) | Anthropic API → Opus (멀티모달) |
+| 블록 검색 | FAISS + bge-m3 |
+| 템플릿 | Jinja2 |
+| 렌더링 | CSS Grid + 디자인 토큰 (1280×720) |
+| 렌더링 측정 | Selenium headless Chrome |
+| SVG 시각화 | svg_calculator.py (N개 동적 배치) |
+| 이미지 | Pillow (크기 측정) + base64 인라인 |
+| 폰트 | Pretendard Variable |
+| 공간 계산 | space_allocator.py (결정론적) |
+
+---
## 설치 및 실행
-### 설치
-
```bash
+# 설치
cd design_agent
-python -m venv .venv
-.venv/Scripts/activate # Windows
pip install -e .
-```
-### FAISS 인덱스 빌드
-
-```bash
+# FAISS 인덱스 빌드 (블록 추가/수정 시)
python scripts/build_block_index.py
-```
-### 환경 변수
-
-`.env` 파일:
-```env
+# .env 설정
ANTHROPIC_API_KEY=sk-ant-...
KEI_API_URL=http://localhost:8000
LOG_LEVEL=DEBUG
```
-### 실행
-
```bash
-# 터미널 1: Kei 백엔드 (Opus 실장 + 편집자 역할)
+# 터미널 1: Kei API (필수)
cd D:\ad-hoc\kei\persona_agent
-uvicorn backend.main:app --reload --host 127.0.0.1 --port 8000
+python -m uvicorn backend.main:app --host 127.0.0.1 --port 8000
# 터미널 2: Design Agent
cd D:\ad-hoc\kei\design_agent
-uvicorn src.main:app --reload --host 127.0.0.1 --port 8001
+python -m uvicorn src.main:app --host 127.0.0.1 --port 8001 --reload
```
접속: http://localhost:8001
+---
+
## 프로젝트 구조
```
design_agent/
-├── CLAUDE.md 프로젝트 규칙 + 5단계 프로세스
-├── PLAN.md 태스크 계획
-├── PROGRESS.md 진행 상황
-├── IMPROVEMENT.md 개선 계획 (Phase A~F)
-├── IMPROVEMENT-PHASE-{A~D}.md 각 Phase 실행 상세
-├── README.md 이 파일
-├── pyproject.toml
-├── .env API 키
-│
-├── src/ 파이프라인 코드
-│ ├── main.py FastAPI 서버 (포트 8001)
-│ ├── config.py 설정 (pydantic-settings)
-│ ├── kei_client.py 1단계: Kei API → 꼭지 추출
-│ ├── design_director.py 2단계: 프리셋 선택 + Opus 추천 + 블록 매핑
-│ ├── content_editor.py 3단계: Kei API → 텍스트 정리
-│ ├── pipeline.py 5단계 파이프라인 (디자인 조정 + 재검토 루프)
-│ ├── renderer.py 4단계: HTML 조립 (SVG 전처리 + CSS 변수 override)
-│ ├── block_search.py FAISS 블록 검색 모듈
-│ ├── svg_calculator.py SVG 좌표 계산 (cos/sin N개 배치)
-│ └── image_utils.py 이미지 크기 측정 + base64 삽입
-│
-├── scripts/
-│ └── build_block_index.py FAISS 인덱스 빌드 스크립트
+├── src/
+│ ├── main.py FastAPI 서버 (포트 8001)
+│ ├── config.py 설정 (pydantic-settings)
+│ ├── pipeline.py 파이프라인 오케스트레이션 (6단계)
+│ ├── kei_client.py Kei API 클라이언트 (1A, 1B, 검수, 넘침 판단)
+│ ├── design_director.py 2단계: 프리셋 + Kei 블록 확정 + Sonnet zone 배치
+│ ├── content_editor.py 3단계: Kei API 텍스트 편집
+│ ├── renderer.py 4단계: HTML 조립 (컨테이너 grid + Jinja2)
+│ ├── space_allocator.py 컨테이너 스펙 계산 + 블록 스펙 확정 (Phase O)
+│ ├── slide_measurer.py Selenium 렌더링 측정 + 스크린샷 (Phase L/N)
+│ ├── block_search.py FAISS 블록 검색
+│ ├── svg_calculator.py SVG 좌표 계산 (N개 동적 배치)
+│ ├── image_utils.py 이미지 크기 측정 + base64 삽입
+│ └── sse_utils.py SSE 스트리밍 유틸
│
├── templates/
-│ ├── slide-base.html 슬라이드 베이스 (다중 페이지 + 인쇄 JS)
-│ ├── catalog.yaml 블록 카탈로그 (38개, height_cost 포함)
-│ └── blocks/ 블록 라이브러리 (6 카테고리, 38개)
-│ ├── INDEX.md 전체 인덱스
-│ ├── headers/ (5) 타이틀, 꼭지 헤더
-│ ├── cards/ (10) 카드 계열
-│ ├── tables/ (3) 비교 테이블
-│ ├── visuals/ (10) 다이어그램, 관계도 (SVG)
-│ ├── emphasis/ (13) 강조, 인용, 결론, 자세히보기
-│ ├── media/ (5) 이미지/미디어
-│ └── media/ (5) 이미지/미디어
+│ ├── slide-base.html 슬라이드 베이스
+│ ├── catalog.yaml 블록 카탈로그 (38개, when/not_for/purpose_fit)
+│ └── blocks/ 블록 라이브러리 (6 카테고리)
│
-├── static/
-│ ├── index.html 프론트엔드 (이미지 경로 입력 팝업 포함)
-│ ├── tokens.css 디자인 토큰
-│ └── base.css 기본 슬라이드 스타일
+├── scripts/
+│ ├── build_block_index.py FAISS 인덱스 빌드
+│ └── generate_run_report.py 실행 리포트 생성
│
-├── data/ 로컬 데이터 (gitignored)
-│ ├── block_index.faiss FAISS 벡터 인덱스
-│ └── block_metadata.json 인덱스 메타데이터
+├── static/ 프론트엔드 (index.html, CSS)
+├── data/ 로컬 데이터 (runs/, FAISS 인덱스)
+├── docs/ 기술 조사, Figma 분석
│
-├── docs/
-│ ├── RESEARCH.md 기술 조사
-│ ├── PHASE2-PLAN.md Phase 2 계획
-│ ├── PHASE2-PROCESS.md Phase 2 실행 프로세스
-│ ├── PHASE2-TECH-REVIEW.md Phase 2 기술 검토
-│ ├── figma-screenshots/ Figma 스크린샷 (16장)
-│ ├── figma-assets/ Figma 에셋
-│ ├── figma-analysis/ 노드 구조 분석
-│ └── block-tests/ 블록 테스트 HTML
-│
-└── tests/
+├── IMPROVEMENT.md 개선 계획 총괄 (Phase A~O)
+├── IMPROVEMENT-PHASE-*.md 각 Phase 상세
+└── PROGRESS.md 진행 상황 추적
```
-## 핵심 원칙
-
-- **모든 판단은 AI 사고. 하드코딩 없음**
-- 텍스트가 기준. 디자인이 텍스트에 맞춤 (텍스트를 자르지 않음)
-- 이미지 원본 그대로, 크기만 조절 (object-fit: contain)
-- 컨테이너 예산(zone별 높이 px) 안에서 블록 배치
-- grid는 코드가 결정. Sonnet은 blocks만 판단
-- Kei API 1차 → Sonnet fallback (1단계, 3단계)
-- Kei Persona Agent 코드를 수정하지 않음
+---
## Kei Persona와의 관계
```
-Kei Persona (본체) — localhost:5173/8000
- ├ 대화/생성/피드백/실행 모드
- ├ Opus + RAG (bge-m3 + FAISS)
- └ 독립적으로 동작
+Kei Persona Agent (localhost:8000)
+ ├── Opus + RAG + 세션 컨텍스트
+ ├── 도메인 지식 (건설/DX/BIM)
+ └── 대화/생성/피드백/실행 모드
-Design Agent (이 프로젝트) — localhost:8001
- ├ 슬라이드 생성 전용
- ├ Kei API로 실장(1단계) + 편집자(3단계) + 블록 추천(2단계 A-2) 호출
- ├ FAISS 블록 검색 (bge-m3, Kei와 동일 모델)
- └ 독립적으로 동작 (Kei 없이도 Sonnet fallback)
+Design Agent (localhost:8001, 이 프로젝트)
+ ├── 슬라이드 생성 전용
+ ├── Kei API로 실장(1단계) + 편집자(3단계) + 블록 확정(2단계) 호출
+ ├── 최종 검수(5단계)는 Opus 직접 호출 (멀티모달 스크린샷)
+ └── 두 프로젝트는 독립. 코드 공유 없음. API 연동만.
```
-
-두 프로젝트는 완전히 독립. 코드 공유 없음. API 연동만.
diff --git a/scripts/generate_run_report.py b/scripts/generate_run_report.py
new file mode 100644
index 0000000..793a9b7
--- /dev/null
+++ b/scripts/generate_run_report.py
@@ -0,0 +1,387 @@
+"""파이프라인 실행 리포트 생성기.
+
+data/runs/{run_id}/ 의 중간 산출물을 읽어
+단계별 진행 과정을 한눈에 볼 수 있는 HTML 리포트를 생성한다.
+
+사용법:
+ python scripts/generate_run_report.py # 최신 run
+ python scripts/generate_run_report.py 1774572796252 # 특정 run
+"""
+from __future__ import annotations
+
+import json
+import sys
+from pathlib import Path
+
+PROJECT_ROOT = Path(__file__).parent.parent
+RUNS_DIR = PROJECT_ROOT / "data" / "runs"
+
+
+def load_json(path: Path) -> dict | list | None:
+ if not path.exists():
+ return None
+ with open(path, encoding="utf-8") as f:
+ return json.load(f)
+
+
+def load_text(path: Path) -> str | None:
+ if not path.exists():
+ return None
+ return path.read_text(encoding="utf-8")
+
+
+def generate_report(run_id: str) -> str:
+ run_dir = RUNS_DIR / run_id
+
+ if not run_dir.exists():
+ return f"
Run not found: {run_id}
"
+
+ # 데이터 로드
+ step1 = load_json(run_dir / "step1_analysis.json")
+ step1b = load_json(run_dir / "step1b_concepts.json")
+ step2 = load_json(run_dir / "step2_layout.json")
+ step2b = load_json(run_dir / "step2b_allocation.json")
+ step3 = load_json(run_dir / "step3_filled_blocks.json")
+ step4_css = load_json(run_dir / "step4_css_adjustment.json")
+ step4_measure = load_json(run_dir / "step4_measurement_round1.json")
+ step5 = load_json(run_dir / "step5_review_round1.json")
+ final_html = load_text(run_dir / "final.html")
+
+ html = f"""
+
+
+
+
파이프라인 리포트 — Run {run_id}
+
+
+
+
+
Design Agent 파이프라인 리포트
+
Run ID: {run_id} | 생성: {_ts_to_str(run_id)}
+"""
+
+ # ── Step 1A ──
+ if step1:
+ topics = step1.get("topics", [])
+ page_struct = step1.get("page_structure", {})
+ html += f"""
+
+
+
원본 콘텐츠를 분석하여 핵심 메시지, 꼭지 구조, 페이지 비중을 설계한다.
+
+
+ | 항목 | 값 |
+ | 제목 | {step1.get('title','')} |
+ | 핵심 메시지 | {step1.get('core_message','')} |
+ | 정보 구조 | {step1.get('info_structure','')[:200]} |
+
+
+
페이지 구조 (비중)
+
+ | 역할 | topic_ids | 비중(weight) | 시각화 |
+"""
+ colors = {"본심": "#2563eb", "배경": "#64748b", "첨부": "#f59e0b", "결론": "#16a34a"}
+ for role, info in page_struct.items():
+ if isinstance(info, dict):
+ w = info.get("weight", 0)
+ tids = info.get("topic_ids", [])
+ c = colors.get(role, "#94a3b8")
+ bar_w = int(w * 400)
+ html += f'| {role} | {tids} | {w:.0%} | '
+ html += f' |
\n'
+ html += "
\n"
+
+ html += """
꼭지 목록
+
+ | # | 제목 | purpose | role | layer | section_title |
+"""
+ for t in topics:
+ st = t.get("section_title", "")
+ html += f"""
+ | {t.get('id','')} |
+ {t.get('title','')} |
+ {t.get('purpose','')} |
+ {t.get('role','')} |
+ {t.get('layer','')} |
+ {st if st else '-'} |
+
\n"""
+ html += "
\n"
+
+ html += '
▼
\n'
+
+ # ── Step 1B ──
+ if step1b:
+ concepts = step1b.get("concepts", [])
+ html += f"""
+
+
+
각 꼭지의 관계 성격(relation_type), 표현 힌트(expression_hint), 원본 데이터를 구체화한다.
+
+
+ | # | 제목 | relation_type | expression_hint | source_data |
+"""
+ for c in concepts:
+ tid = c.get("topic_id") or c.get("id", "?")
+ html += f"""
+ | {tid} |
+ {c.get('title','')} |
+ {c.get('relation_type','')} |
+ {c.get('expression_hint','')[:120]} |
+ {c.get('source_data','')[:120]} |
+
\n"""
+ html += "
\n"
+
+ html += '
▼
\n'
+
+ # ── Step 2 ──
+ if step2:
+ blocks = step2.get("blocks", [])
+ overflows = step2.get("overflow", [])
+ html += f"""
+
+
+
+ Step A: 규칙 기반 프리셋 선택
+ Step A-2 (Kei): 각 꼭지에 적합한 블록 확정 (코드 레벨 강제)
+ Step B (Sonnet): zone 배치 + char_guide만 결정 (블록 타입 변경 불가)
+
+
+
프리셋: {step2.get('preset','')}
+
+
+ | area | 블록 타입 | purpose | topic | 이유 | 크기 |
+"""
+ for b in blocks:
+ html += f"""
+ | {b.get('area','')} |
+ {b.get('type','')} |
+ {b.get('purpose','')} |
+ {b.get('topic_id','')} |
+ {b.get('reason','')[:100]} |
+ {b.get('size','')} |
+
\n"""
+ html += "
\n"
+
+ if overflows:
+ html += '
높이 초과 예상
\n
\n'
+ html += '| zone | 예산(px) | 합계(px) | 초과(px) |
\n'
+ for o in overflows:
+ html += f'| {o.get("area","")} | {o.get("budget_px","")} | {o.get("total_px","")} | +{o.get("overflow_px","")} |
\n'
+ html += "
\n"
+ html += "
\n"
+
+ html += '
▼
\n'
+
+ # ── Step 2B (Allocation) ──
+ if step2b:
+ html += f"""
+
+
+
Kei의 비중(weight)을 기반으로 각 zone 내 블록별 max_height_px와 max_chars를 수학적으로 계산한다.
+
{json.dumps(step2b, ensure_ascii=False, indent=2)}
+
+"""
+ html += '
▼
\n'
+
+ # ── Step 3 ──
+ if step3:
+ filled = step3.get("blocks", [])
+ html += f"""
+
+
+
원본 콘텐츠에서 각 블록의 슬롯에 맞는 텍스트를 추출/편집한다. 원본 보존 원칙.
+
+
+ | area | 블록 타입 | topic | 글자 수 | 데이터 (요약) |
+"""
+ for b in filled:
+ data_str = json.dumps(b.get("data", {}), ensure_ascii=False)
+ preview = data_str[:200] + ("..." if len(data_str) > 200 else "")
+ html += f"""
+ | {b.get('area','')} |
+ {b.get('type','')} |
+ {b.get('topic_id','')} |
+ {b.get('char_count','')} |
+ {preview} |
+
\n"""
+ html += "
\n"
+
+ html += '
▼
\n'
+
+ # ── Step 4 (CSS + Measurement) ──
+ html += f"""
+
+
+
텍스트 양에 맞게 CSS 변수(폰트, 여백)를 조정하고 Jinja2로 HTML을 조립한다.
+"""
+ if step4_css:
+ html += f'
{json.dumps(step4_css, ensure_ascii=False, indent=2)}
\n'
+
+ if step4_measure:
+ slide = step4_measure.get("slide", {})
+ zones = step4_measure.get("zones", {})
+ slide_status = '
OK' if not slide.get("overflowed") else f'
+{slide.get("excess_px",0)}px 초과'
+
+ html += f"""
+
Phase L: Selenium 렌더링 측정
+
슬라이드 전체: {slide.get('scrollHeight','?')}px / {slide.get('clientHeight','?')}px — {slide_status}
+
+ | zone | scrollHeight | clientHeight | 상태 | 블록 상세 |
+"""
+ for zn, zd in zones.items():
+ z_status = 'OK' if not zd.get("overflowed") else f'+{zd.get("excess_px",0)}px'
+ block_details = ", ".join(
+ f'{bl.get("block_type","?")}:{bl.get("scrollHeight","?")}px'
+ for bl in zd.get("blocks", [])
+ )
+ html += f'| {zn} | {zd.get("scrollHeight","")} | {zd.get("clientHeight","")} | {z_status} | {block_details} |
\n'
+ html += "
\n"
+ html += "
\n"
+
+ html += '
▼
\n'
+
+ # ── Step 5 ──
+ if step5:
+ needs = step5.get("needs_adjustment", False)
+ issues = step5.get("issues", [])
+ adjs = step5.get("adjustments", [])
+ html += f"""
+
+
+
렌더링 결과를 Kei가 검수. overflow 없으면 skip.
+
조정 필요: {'예' if needs else '아니오'}
+"""
+ if issues:
+ html += '
이슈:
\n'
+ for iss in issues:
+ html += f'- {iss}
\n'
+ html += '
\n'
+ if adjs:
+ html += '
조정 사항:
\n
| area | action | detail |
\n'
+ for adj in adjs:
+ html += f'| {adj.get("block_area","")} | {adj.get("action","")} | {adj.get("detail","")[:100]} |
\n'
+ html += '
\n'
+ html += "
\n"
+ else:
+ html += """
+
+
+
Skip — overflow 없음.
+
+"""
+
+ html += '
▼
\n'
+
+ # ── Final ──
+ if final_html:
+ # iframe으로 최종 결과물 미리보기
+ import html as html_lib
+ escaped = html_lib.escape(final_html)
+ html += f"""
+
+"""
+
+ html += """
+
+
+"""
+ return html
+
+
+def _ts_to_str(run_id: str) -> str:
+ try:
+ from datetime import datetime
+ ts = int(run_id) / 1000
+ return datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M:%S")
+ except Exception:
+ return run_id
+
+
+def main():
+ if len(sys.argv) > 1:
+ run_id = sys.argv[1]
+ else:
+ # 최신 run 자동 선택
+ runs = sorted(RUNS_DIR.iterdir(), key=lambda p: p.name, reverse=True)
+ if not runs:
+ print("data/runs/ 에 실행 결과가 없습니다.")
+ sys.exit(1)
+ run_id = runs[0].name
+
+ print(f"리포트 생성: run={run_id}")
+ report = generate_report(run_id)
+
+ output_path = RUNS_DIR / run_id / "report.html"
+ output_path.write_text(report, encoding="utf-8")
+ print(f"저장: {output_path}")
+ print(f"브라우저에서 열기: file:///{output_path}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/block_search.py b/src/block_search.py
index b2d41ff..f3677bc 100644
--- a/src/block_search.py
+++ b/src/block_search.py
@@ -176,13 +176,20 @@ def search_blocks_for_topics(
def _build_query(topic: dict) -> str:
- """꼭지 정보에서 검색 쿼리를 생성한다."""
+ """꼭지 정보에서 검색 쿼리를 생성한다. (Phase M: 역할+관계+표현 추가)"""
parts = [
topic.get("title", ""),
topic.get("summary", ""),
f"역할: {topic.get('role', 'flow')}",
f"레이어: {topic.get('layer', 'core')}",
]
+ # Phase M: purpose, relation_type, expression_hint 추가
+ if topic.get("purpose"):
+ parts.append(f"목적: {topic['purpose']}")
+ if topic.get("relation_type"):
+ parts.append(f"관계: {topic['relation_type']}")
+ if topic.get("expression_hint"):
+ parts.append(f"표현: {topic['expression_hint']}")
if topic.get("content_type"):
parts.append(f"콘텐츠: {topic['content_type']}")
return ". ".join(p for p in parts if p)
diff --git a/src/content_editor.py b/src/content_editor.py
index a52ba43..23a2129 100644
--- a/src/content_editor.py
+++ b/src/content_editor.py
@@ -4,8 +4,7 @@
Kei API를 통해 도메인 전문가로서 각 슬롯 텍스트를 정리한다.
팀장의 글자 수 가이드를 참고하되 내용 의미가 우선.
-1차: Kei API (persona + RAG + 도메인 지식)
-fallback: Anthropic API 직접 호출
+Kei API 필수. fallback 없음. 성공할 때까지 무한 재시도.
"""
from __future__ import annotations
@@ -53,6 +52,21 @@ EDITOR_PROMPT = """당신은 도메인 전문가이자 콘텐츠 편집자이다
- summary: 슬라이드 표면에 보일 요약 (3줄 이내)
- detail: 펼치면 보일 전체 내용
+## purpose별 분량 원칙 (가이드라인)
+- 문제제기: max 100자 (2-3줄). 간결한 도입부. 장황하지 않게.
+- 근거사례: max 150자. 핵심만 짧게. 상세는 자세히보기.
+- 핵심전달: 200-400자. 충분히 구조화. 이것이 슬라이드의 주인공.
+- 용어정의: 각 용어 max 50자. sidebar에서 짧게 정의.
+- 결론강조: max 40자. 기억할 1문장.
+- 비교 블록 사용 시: 비교 목적(왜 비교하는가)을 첫 행 또는 상단에 요약.
+
+## source 슬롯 규칙 (절대 규칙)
+- source 슬롯에는 반드시 정보원(출처)을 넣는다
+- 꼭지 제목, 주제어, 섹션명을 source에 넣지 마라
+- 출처가 원본에 없으면 source 슬롯을 비워라 (빈 문자열)
+- 올바른 예: '국토교통부, 2020', 'IBM, 2011'
+- 잘못된 예: '용어의 혼용', 'DX와 BIM 개념'
+
## JSON 형식으로만 응답한다. 설명 없이 JSON만."""
@@ -103,33 +117,64 @@ async def fill_content(
guide_lines = [f" {k}: ~{v}자" for k, v in char_guide.items()]
req_text += "\n 글자 수 가이드 (참고, 의미 우선):\n" + "\n".join(guide_lines)
+ # Phase O-4: 컨테이너 기반 블록 스펙 전달
+ container_h = block.get("_container_height_px")
+ if container_h:
+ max_items = block.get("_max_items", "제한 없음")
+ max_chars_item = block.get("_max_chars_per_item", "제한 없음")
+ max_chars_total = block.get("_max_chars_total", "제한 없음")
+ font_size = block.get("_font_size_px", 15.2)
+ req_text += (
+ f"\n ★ 컨테이너 제약 (절대 준수):"
+ f"\n - 컨테이너 높이: {container_h}px"
+ f"\n - 최대 항목 수: {max_items}개"
+ f"\n - 항목당 최대 글자 수: {max_chars_item}자"
+ f"\n - 총 최대 글자 수: {max_chars_total}자"
+ f"\n - 폰트 크기: {font_size}px"
+ f"\n 이 제약을 넘기면 컨테이너 밖으로 넘친다. 반드시 지켜라."
+ )
+
slot_requirements.append(req_text)
page_label = ""
if len(layout_concept.get("pages", [])) > 1:
page_label = f" (페이지 {page_idx + 1}/{len(layout_concept['pages'])})"
+ # Phase M: 토픽별 source 정보 추출 (P-9 원본 보존 강화)
+ source_section = ""
+ if analysis:
+ source_lines = []
+ for topic in analysis.get("topics", []):
+ tid = topic.get("id")
+ hint = topic.get("source_hint", "")
+ data = topic.get("source_data", "")
+ if hint or data:
+ source_lines.append(
+ f"- 토픽 {tid} ({topic.get('purpose', '')}): "
+ f"{hint}{' / ' + data if data else ''}"
+ )
+ if source_lines:
+ source_section = (
+ "\n\n## 토픽별 원본 데이터 (이 텍스트에서 추출하라. 재작성 금지.)\n"
+ + "\n".join(source_lines)
+ )
+
user_prompt = (
f"## 원본 콘텐츠\n{content}\n\n"
f"## 블록 배치{page_label}\n"
+ "\n".join(slot_requirements)
+ + source_section
+ "\n\n## 요청\n"
"위 블록별로 슬롯에 들어갈 텍스트를 정리하여 JSON으로 반환해줘.\n"
- "내용의 의미를 살려서 편집해. 글자 수 가이드는 참고만.\n"
+ "원본에서 추출하라. 재작성하지 마라. 축약만 허용.\n"
"자세히보기 대상 블록은 summary + detail 두 버전을 작성해.\n"
"형식:\n"
'{"blocks": [{"area": "...", "type": "...", "topic_id": 1, "data": {슬롯 키-값}}]}'
)
try:
- # Kei API만 사용. Sonnet fallback 없음.
- result_text = await _call_kei_editor(user_prompt)
-
- # G-6: Kei API 실패 시 None 가드
- if result_text is None:
- logger.warning("Kei API 편집 실패. 기본값 적용.")
- _apply_defaults(blocks)
- continue
+ # Kei API만 사용. fallback 없음. 성공할 때까지 무한 재시도.
+ result_text = await _call_kei_editor_with_retry(user_prompt)
filled = _parse_json(result_text)
@@ -140,7 +185,14 @@ async def fill_content(
if filled_block.get("topic_id"):
for orig_block in blocks:
if orig_block.get("topic_id") == filled_block.get("topic_id"):
- orig_block["data"] = filled_block.get("data", {})
+ # data 덮어쓰되 column_override 등 기존 메타 보존 (J-6)
+ new_data = filled_block.get("data", {})
+ preserved = {}
+ if "data" in orig_block:
+ for k in ("column_override",):
+ if k in orig_block["data"]:
+ preserved[k] = orig_block["data"][k]
+ orig_block["data"] = {**new_data, **preserved}
matched = True
break
# 2차: area + type으로 매칭 (topic_id 없을 때)
@@ -151,7 +203,14 @@ async def fill_content(
and orig_block.get("type") == filled_block.get("type")
and "data" not in orig_block
):
- orig_block["data"] = filled_block.get("data", {})
+ # data 덮어쓰되 column_override 등 기존 메타 보존 (J-6)
+ new_data = filled_block.get("data", {})
+ preserved = {}
+ if "data" in orig_block:
+ for k in ("column_override",):
+ if k in orig_block["data"]:
+ preserved[k] = orig_block["data"][k]
+ orig_block["data"] = {**new_data, **preserved}
break
logger.info(
@@ -159,107 +218,63 @@ async def fill_content(
f"{len(filled['blocks'])}개 블록"
)
else:
- logger.warning(f"텍스트 정리 파싱 실패 (페이지 {page_idx + 1}). 기본값.")
- _apply_defaults(blocks)
+ logger.warning(f"텍스트 정리 파싱 실패 (페이지 {page_idx + 1}). 재시도 필요하지만 텍스트는 받았으므로 진행.")
except Exception as e:
logger.error(f"텍스트 편집자 호출 실패: {e}", exc_info=True)
- _apply_defaults(blocks)
+ raise
return layout_concept
-async def _call_kei_editor(prompt: str) -> str | None:
- """Kei API를 통해 텍스트 편집을 요청한다. SSE 스트리밍으로 실시간 수신.
+async def _call_kei_editor_with_retry(prompt: str) -> str:
+ """Kei API를 통해 텍스트 편집을 요청한다. 성공할 때까지 무한 재시도.
Kei persona의 도메인 지식 + RAG를 활용하여
건설/DX 분야 전문 용어를 정확하게 유지하면서 편집.
+ fallback 없음. Kei API가 응답할 때까지 기다린다.
"""
+ import asyncio
+
kei_url = getattr(settings, "kei_api_url", "http://localhost:8000")
-
full_prompt = EDITOR_PROMPT + "\n\n" + prompt
+ RETRY_INTERVAL = 10
+ attempt = 0
- try:
- async with httpx.AsyncClient(timeout=None) as client:
- async with client.stream(
- "POST",
- f"{kei_url}/api/message",
- json={
- "message": full_prompt,
- "session_id": "design-agent-editor",
- "mode_hint": "chat",
- },
- timeout=None,
- ) as response:
- if response.status_code != 200:
- logger.warning(f"Kei API (editor) HTTP {response.status_code}")
- return None
+ while True:
+ attempt += 1
+ try:
+ async with httpx.AsyncClient(timeout=None) as client:
+ async with client.stream(
+ "POST",
+ f"{kei_url}/api/message",
+ json={
+ "message": full_prompt,
+ "session_id": "design-agent-editor",
+ "mode_hint": "chat",
+ },
+ timeout=None,
+ ) as response:
+ if response.status_code != 200:
+ logger.warning(f"Kei API (editor) HTTP {response.status_code} (시도 {attempt})")
+ await asyncio.sleep(RETRY_INTERVAL)
+ continue
- full_text = await stream_sse_tokens(response)
+ full_text = await stream_sse_tokens(response)
- if full_text:
- return full_text
+ if full_text:
+ return full_text
- logger.warning("Kei API (editor) 텍스트 추출 실패")
- return None
+ logger.warning(f"Kei API (editor) 텍스트 추출 실패 (시도 {attempt})")
+ await asyncio.sleep(RETRY_INTERVAL)
- except Exception as e:
- logger.warning(f"Kei API (editor) 호출 실패: {e}")
- return None
+ except Exception as e:
+ logger.warning(f"Kei API (editor) 호출 실패 (시도 {attempt}): {e}")
+ await asyncio.sleep(RETRY_INTERVAL)
-def _apply_defaults(blocks: list[dict[str, Any]]) -> None:
- """실패 시 기본 데이터 적용."""
- defaults = {
- # headers/
- "section-title-with-bg": {"title_ko": "(제목)"},
- "section-header-bar": {"title": "(섹션)"},
- "topic-left-right": {"title": "(소제목)", "description": ""},
- "topic-center": {"title": "(제목)"},
- "topic-numbered": {"number": "1", "title": "(단계)"},
- # cards/
- "card-image-3col": {"cards": []},
- "card-dark-overlay": {"cards": []},
- "card-tag-image": {"cards": []},
- "card-icon-desc": {"cards": []},
- "card-compare-3col": {"cards": []},
- "card-step-vertical": {"steps": []},
- "card-image-round": {"cards": []},
- "card-stat-number": {"stats": []},
- "card-numbered": {"items": []},
- # tables/
- "compare-3col-badge": {"headers": [], "rows": []},
- "compare-2col-split": {"left_title": "A", "right_title": "B", "rows": []},
- "table-simple-striped": {"headers": [], "rows": []},
- # visuals/
- "venn-diagram": {"center_label": "관계도", "items": [], "center_sub": "", "description": ""},
- "circle-gradient": {"label": "(라벨)"},
- "compare-pill-pair": {"left_label": "A", "right_label": "B"},
- "process-horizontal": {"steps": []},
- "flow-arrow-horizontal": {"steps": []},
- "keyword-circle-row": {"keywords": []},
- # emphasis/
- "quote-big-mark": {"quote_text": "(인용)"},
- "quote-question": {"question": "(질문)"},
- "comparison-2col": {"left_title": "A", "left_content": "-", "right_title": "B", "right_content": "-"},
- "banner-gradient": {"text": "(배너)"},
- "dark-bullet-list": {"bullets": []},
- "highlight-strip": {"segments": []},
- "callout-solution": {"title": "(솔루션)", "description": ""},
- "callout-warning": {"title": "(경고)", "description": ""},
- "tab-label-row": {"tabs": []},
- "divider-text": {"text": "구분"},
- # media/
- "image-row-2col": {"images": []},
- "image-grid-2x2": {"images": []},
- "image-side-text": {"image_src": ""},
- "image-full-caption": {"src": ""},
- "image-before-after": {"before_src": "", "after_src": ""},
- }
- for block in blocks:
- if "data" not in block:
- block["data"] = defaults.get(block.get("type", ""), {})
+# _apply_defaults 삭제됨 — Kei API 무한 재시도로 fallback 불필요.
def _parse_json(text: str) -> dict[str, Any] | None:
diff --git a/src/design_director.py b/src/design_director.py
index 8f50389..ab5cec5 100644
--- a/src/design_director.py
+++ b/src/design_director.py
@@ -11,7 +11,6 @@ import re
from pathlib import Path
from typing import Any
-import anthropic
import httpx
import yaml
@@ -446,90 +445,9 @@ def _load_catalog() -> str:
- banner-gradient: 섹션 강조 배너."""
-STEP_B_PROMPT = """당신은 디자인 팀장이다. 레이아웃 프리셋이 이미 선택되었다.
-당신의 핵심 역할: **컨테이너(zone)의 크기 예산 안에서** 블록을 배정하는 것이다.
-
-## 슬라이드 물리적 제약 (절대 조건)
-- 프레임: 1280×720px (16:9 고정)
-- 패딩: 상하좌우 40px → 가용 영역: 1200×640px
-- 블록 간 간격: 20px
-- **overflow: hidden** — 넘치는 콘텐츠는 잘려서 보이지 않는다!
-
-## 선택된 레이아웃 프리셋: {preset_name}
-{preset_description}
-
-### CSS Grid (변경하지 마라):
-grid-template-areas: {grid_areas}
-grid-template-columns: {grid_columns}
-grid-template-rows: {grid_rows}
-
-### Zone별 컨테이너 예산:
-{zone_descriptions}
-
-## ★ 사고 순서 (반드시 이 순서로 판단하라)
-
-### 1단계: 컨테이너 크기 확인
-위 zone별 높이 예산(px)과 너비(%)를 확인한다. 이것이 절대 제약이다.
-header/footer는 고정이므로 건드리지 않는다.
-
-### 2단계: 꼭지 → zone 배정
-- flow 꼭지 → body / left / hero zone
-- reference 꼭지 → sidebar zone
-- conclusion 꼭지 → footer zone (banner-gradient 권장)
-
-### 3단계: zone별 블록 선택 + 높이 예산 계산
-각 zone에 대해:
- a) 배정된 꼭지 수를 확인한다
- b) catalog에서 블록을 선택한다 (각 블록의 height_cost 확인!)
- c) 총 높이를 계산한다: Σ(블록 height_cost) + 간격(20px × (블록수-1))
- d) **총 높이 ≤ zone 예산** 인지 반드시 확인한다
- e) 초과 시: ① 더 작은(compact) 블록으로 교체 ② 꼭지를 다음 페이지로 분리
-
-### 4단계: 최종 검증
-모든 zone의 블록 총 높이가 예산 이내인지 재확인한 후 출력한다.
-
-## 블록 선택 규칙 (절대 규칙)
-- **아래 허용 목록에 있는 블록만 선택하라. 목록에 없는 블록은 절대 사용 금지.**
-- **텍스트 블록 우선** — 텍스트로 충분히 전달 가능하면 시각화(SVG) 블록 쓰지 마라
-- **시각화 블록은 높이 비용이 크다** — 한 zone에 시각화 블록은 최대 1개
-- 너비 35% 이하 zone(sidebar)에는 카드 1열, 시각화 블록 금지
-- catalog의 when/not_for와 height_cost를 반드시 읽고 선택
-- 같은 블록 타입 반복 금지 — 다양한 블록 활용
-- **section-title-with-bg는 body/sidebar/footer zone에서 사용 금지.** 이 블록은 자세히보기 전용 페이지 상단에만 사용.
-- 각 꼭지의 relation_type과 expression_hint를 보고 적합한 블록을 선택하라
-
-## purpose 기반 블록 선택 가이드 (참고, 강제 아님)
-각 꼭지의 purpose에 맞는 블록 계열을 선택하라:
-- 문제제기 → callout-warning, quote-big-mark, quote-question
-- 근거사례 → quote-big-mark (출처 포함), card-icon-desc (항목 나열)
-- 핵심전달 → comparison-2col, compare-pill-pair, compare-2col-split
-- 용어정의 → card-icon-desc (정의+출처), card-numbered (순서 있으면)
-- 결론강조 → banner-gradient (footer)
-- 구조시각화 → venn-diagram (단독 배치)
-
-## 허용된 블록 id 목록 (이 목록에 없는 블록은 절대 선택하지 마라)
-{allowed_ids}
-
-## 블록 상세 설명 (위 목록의 when/not_for 참고)
-{catalog}
-
-## 출력 형식 (반드시 JSON만. 설명 없이.)
-grid는 이미 확정되었으므로 출력하지 마라. blocks 배열만 출력한다.
-```json
-{{{{
- "blocks": [
- {{{{
- "area": "zone이름",
- "type": "블록타입",
- "topic_id": 1,
- "purpose": "문제제기|근거사례|핵심전달|용어정의|결론강조|구조시각화",
- "reason": "이유",
- "size": "small|medium|large",
- "char_guide": {{{{"slot": 글자수}}}}
- }}}}
- ]
-}}}}
-```"""
+# Step B(Sonnet) 제거됨 — Phase O에서 Kei 확정 + 코드 검증으로 대체.
+# STEP_B_PROMPT, _fallback_layout, PURPOSE_FALLBACK, DOWNGRADE_MAP, _downgrade_fallback 삭제.
+# Step B(Sonnet) 제거됨 — Phase O에서 Kei 확정 + 코드 검증으로 대체.
async def _opus_block_recommendation(
@@ -537,16 +455,16 @@ async def _opus_block_recommendation(
block_candidates: str,
preset_name: str,
preset: dict[str, Any],
+ container_specs: dict | None = None,
) -> dict[str, Any] | None:
- """P2-C: Opus(Kei API)가 블록 후보에서 최종 블록을 추천한다.
+ """Phase O: Kei(Opus)가 컨테이너 제약을 보고 블록을 확정한다.
Kei API를 통해 Opus가 사고하여:
- - 각 꼭지에 가장 적합한 블록 선정
- - 배치 방향/크기 가이드 제시
+ - 컨테이너 크기(px)에 맞는 블록 선정
+ - height_cost가 컨테이너보다 큰 블록은 선택 금지
- 도메인 지식 기반 판단
반드시 Kei API 경유. Anthropic 직접 호출 절대 금지.
- fallback: None 반환 → Step B(Sonnet)가 직접 선택.
"""
import httpx
@@ -563,6 +481,20 @@ async def _opus_block_recommendation(
for t in analysis.get("topics", [])
)
+ # Phase O: 컨테이너 제약 텍스트
+ container_text = ""
+ if container_specs:
+ from src.space_allocator import ContainerSpec
+ lines = ["## 컨테이너 제약 (반드시 준수)\n각 꼭지는 아래 컨테이너 안에 들어가야 한다. height_cost가 허용 범위를 초과하면 선택 금지.\n"]
+ for role, spec in container_specs.items():
+ for tid in spec.topic_ids:
+ lines.append(
+ f"- 꼭지 {tid}: 컨테이너 {spec.height_px}px × {spec.width_px}px, "
+ f"허용 height_cost: **{spec.max_height_cost} 이하**, "
+ f"최대 항목 수: {spec.block_constraints.get('max_items', '?')}개"
+ )
+ container_text = "\n".join(lines) + "\n\n"
+
prompt = (
f"슬라이드 디자인 블록 추천을 해줘.\n\n"
f"## 프리셋: {preset_name}\n{preset['description']}\n\n"
@@ -572,12 +504,13 @@ async def _opus_block_recommendation(
f"- reference 꼭지 → sidebar zone\n"
f"- conclusion 꼭지 → **반드시 footer zone** (banner-gradient 권장)\n"
f"- sidebar(35%)에는 시각화 블록 금지\n\n"
+ f"{container_text}"
f"## 꼭지 목록\n{topics_text}\n\n"
f"## 블록 후보 (FAISS 검색 결과)\n{block_candidates}\n\n"
f"## 요청\n"
f"각 꼭지에 가장 적합한 블록을 추천해줘.\n"
- f"도메인 지식을 활용하여 콘텐츠 성격에 맞는 블록을 선택하고,\n"
- f"zone별 높이 예산을 고려하여 배치 방향과 크기 가이드를 제시해.\n\n"
+ f"컨테이너 높이(px)와 허용 height_cost를 반드시 확인하고,\n"
+ f"도메인 지식을 활용하여 콘텐츠 성격에 맞는 블록을 선택해.\n\n"
f"## 출력 형식 (JSON만)\n"
f'{{"recommendations": ['
f'{{"topic_id": 1, "block_type": "...", "area": "...", '
@@ -627,6 +560,7 @@ async def _opus_block_recommendation(
async def create_layout_concept(
content: str,
analysis: dict[str, Any],
+ container_specs: dict | None = None,
) -> dict[str, Any]:
"""2단계: Step A(프리셋) + Step B(블록 매핑).
@@ -641,231 +575,153 @@ async def create_layout_concept(
preset_name = select_preset(analysis)
preset = LAYOUT_PRESETS[preset_name]
- # Step B: 프리셋 내 블록 매핑 (Sonnet)
- client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
-
- # P2-A: FAISS 검색으로 관련 블록만 추출 (fallback: catalog 전문)
+ # P2-A: FAISS 검색으로 관련 블록만 추출
from src.block_search import search_blocks_for_topics
topics = analysis.get("topics", [])
catalog_text = search_blocks_for_topics(topics, top_k_per_topic=3, total_max=10)
logger.info(f"[Step A] 블록 후보 검색 완료 (FAISS)")
- # P2-C: Step A-2 — Opus(Kei API)가 블록 추천
+ # Phase N-1: Step A-2 — Kei(Opus)가 블록 확정. Sonnet은 zone + char_guide만.
opus_recommendation = await _opus_block_recommendation(
- analysis, catalog_text, preset_name, preset
+ analysis, catalog_text, preset_name, preset,
+ container_specs=container_specs,
)
- opus_hint = ""
+
+ # Kei 확정 블록 매핑 (topic_id → block_type)
+ kei_confirmed_blocks: dict[int, str] = {}
+ kei_confirmed_areas: dict[int, str] = {}
if opus_recommendation and opus_recommendation.get("recommendations"):
recs = opus_recommendation["recommendations"]
- hint_lines = ["## Opus(실장) 블록 추천 (참고, 최종 선택은 팀장 판단)"]
for rec in recs:
- hint_lines.append(
- f"- 꼭지 {rec.get('topic_id', '?')}: "
- f"{rec.get('block_type', '?')} ({rec.get('area', '?')}) "
- f"— {rec.get('reason', '')}"
- )
- opus_hint = "\n".join(hint_lines)
- logger.info(f"[Step A-2] Opus 추천 {len(recs)}개 → Step B에 전달")
+ # Kei가 topic_id 또는 id로 응답할 수 있으므로 양쪽 체크
+ tid = rec.get("topic_id") or rec.get("id")
+ if tid is not None:
+ kei_confirmed_blocks[tid] = rec.get("block_type", "")
+ kei_confirmed_areas[tid] = rec.get("area", "")
+ logger.info(f"[Step A-2] Kei 블록 확정: {kei_confirmed_blocks}")
else:
- logger.info("[Step A-2] Opus 추천 없음 (Kei API 미연결 또는 실패). Step B가 직접 선택.")
-
- # zone 설명 텍스트 (높이 예산 + 너비 포함)
- zone_desc = "\n".join(
- f"- {name}: {z['desc']} [높이 예산: ~{z['budget_px']}px, 너비: {z['width_pct']}%]"
- for name, z in preset["zones"].items()
- )
-
- # 꼭지 요약
- topics_summary = []
- for t in analysis.get("topics", []):
- role = t.get("role", "flow")
- line = (
- f"꼭지 {t.get('id', '?')}: {t.get('title', '?')} "
- f"[{t.get('layer', '?')}, ROLE:{role}, "
- f"강조:{t.get('emphasis', False)}, "
- f"관계:{t.get('relation_type', '?')}, "
- f"표현:{t.get('expression_hint', '?')}, "
- f"원본데이터:{t.get('source_data', '?')}]"
- )
- if t.get("detail_target"):
- line += " → ★detail_target (callout-solution으로 요약 배치 권장)"
- topics_summary.append(line)
-
- # 허용 블록 ID 목록 생성 (catalog.yaml에 등록된 블록만)
- allowed_ids_list = _get_registered_block_ids()
- allowed_ids_str = ", ".join(sorted(allowed_ids_list))
-
- system = STEP_B_PROMPT.format(
- preset_name=preset_name,
- preset_description=preset["description"],
- grid_areas=preset["grid_areas"],
- grid_columns=preset["grid_columns"],
- grid_rows=preset["grid_rows"],
- zone_descriptions=zone_desc,
- allowed_ids=allowed_ids_str,
- catalog=catalog_text,
- )
-
- info_structure = analysis.get("info_structure", "")
-
- # 이미지 크기 정보 (D-2/D-3: Pillow 측정 결과)
- image_info = ""
- image_sizes = analysis.get("image_sizes", [])
- if image_sizes:
- image_lines = []
- for img in image_sizes:
- line = f"- {img['path']}: {img['width']}×{img['height']}px, {img['orientation']}"
- if img.get("has_text"):
- line += " (텍스트 포함 도표 — 과도한 축소 금지)"
- image_lines.append(line)
- image_info = (
- "\n\n## 이미지 크기 정보\n"
- "가로형(landscape) → 전체 너비 배치 권장. "
- "세로형(portrait) → 텍스트 옆 배치 권장. "
- "텍스트 포함 도표 → 과도한 축소 금지.\n"
- + "\n".join(image_lines)
- )
-
- # Opus 추천이 있으면 user_prompt에 포함
- opus_section = ""
- if opus_hint:
- opus_section = f"\n\n{opus_hint}\n"
-
- user_prompt = (
- f"## 실장 분석 결과\n"
- f"제목: {analysis.get('title', '')}\n"
- f"정보 구조: {info_structure}\n\n"
- f"꼭지 목록:\n" + "\n".join(topics_summary) +
- image_info +
- opus_section +
- f"\n\n## 원본 콘텐츠 (분량 참고)\n{content[:2000]}\n\n"
- f"## 요청\n"
- f"위 꼭지를 프리셋의 zone에 배정하고 블록 타입을 선택해줘.\n"
- f"Opus 추천이 있으면 참고하되, 최종 선택은 팀장 판단.\n"
- f"JSON만."
- )
-
- try:
- response = await client.messages.create(
- model="claude-sonnet-4-20250514",
- max_tokens=2048,
- system=system,
- messages=[{"role": "user", "content": user_prompt}],
- )
-
- result_text = response.content[0].text
- concept = _parse_json(result_text)
-
- # BF-9: Sonnet 출력에서 blocks만 추출. grid는 프리셋에서 강제.
- blocks = None
- if concept:
- if "blocks" in concept:
- # 새 형식: {"blocks": [...]}
- blocks = concept["blocks"]
- elif "pages" in concept:
- # 구 형식 호환: {"pages": [{"blocks": [...]}]}
- all_blocks = []
- for p in concept["pages"]:
- all_blocks.extend(p.get("blocks", []))
- blocks = all_blocks
-
- if blocks is not None:
- # 블록 ID 검증: catalog에 없는 블록은 거부하고 안전한 대체 블록 사용
- registered_ids = _get_registered_block_ids()
- 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"'{fallback}'으로 교체 (purpose={purpose})"
- )
- block["type"] = fallback
-
- # area명 검증: 프리셋 zone에 없으면 기본 zone으로 매핑
- valid_zones = {z for z in preset["zones"] if z != "header"}
- default_zone = "body" if "body" in valid_zones else next(iter(valid_zones))
- for block in blocks:
- if block.get("area") not in valid_zones:
- logger.warning(
- f"zone '{block.get('area')}' → '{default_zone}' 자동 매핑"
- )
- block["area"] = default_zone
-
- # 6번: conclusion 꼭지 → footer zone 강제
- for block in blocks:
- topic = next(
- (t for t in analysis.get("topics", [])
- if t.get("id") == block.get("topic_id")),
- None,
- )
- if topic and topic.get("layer") == "conclusion":
- if block.get("area") != "footer":
- logger.warning(
- f"conclusion 꼭지 {block.get('topic_id')} → footer 강제 이동"
- )
- block["area"] = "footer"
-
- # 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 "")
+ # Kei API 필수. 응답 없으면 성공할 때까지 무한 재시도.
+ import asyncio
+ RETRY_INTERVAL = 10
+ attempt = 0
+ while not opus_recommendation or not opus_recommendation.get("recommendations"):
+ attempt += 1
+ logger.warning(f"[Step A-2] Kei API 응답 없음 (시도 {attempt}). {RETRY_INTERVAL}초 후 재시도...")
+ await asyncio.sleep(RETRY_INTERVAL)
+ opus_recommendation = await _opus_block_recommendation(
+ analysis, catalog_text, preset_name, preset
)
- result = {
- "title": analysis.get("title", "슬라이드"),
- "pages": [{
- "grid_areas": preset["grid_areas"],
- "grid_columns": preset["grid_columns"],
- "grid_rows": preset["grid_rows"],
- "blocks": blocks,
- }],
- }
- if overflows:
- result["overflow"] = overflows
- return result
- else:
- logger.warning("블록 매핑 JSON 파싱 실패. fallback.")
+ # 재시도 성공 → 확정 블록 매핑
+ for rec in opus_recommendation["recommendations"]:
+ tid = rec.get("topic_id") or rec.get("id")
+ if tid is not None:
+ kei_confirmed_blocks[tid] = rec.get("block_type", "")
+ kei_confirmed_areas[tid] = rec.get("area", "")
+ logger.info(f"[Step A-2] Kei 블록 확정 (재시도 후): {kei_confirmed_blocks}")
- except Exception as e:
- logger.error(f"Step B 호출 실패: {e}", exc_info=True)
+ # Phase O: Kei 확정 블록 + 코드 검증으로 직접 layout_concept 생성
+ # Step B(Sonnet) 제거됨 — Kei가 블록/zone을 확정, 코드가 스펙 계산
- # fallback: 프리셋 기반 기본 배치
- # (검증 함수는 아래에 정의)
- return _fallback_layout(analysis, preset_name, preset)
-
-
-def _fallback_layout(
- analysis: dict[str, Any],
- preset_name: str,
- preset: dict[str, Any],
-) -> dict[str, Any]:
- """Step B 실패 시 프리셋 기반 기본 배치."""
blocks = []
- for topic in analysis.get("topics", []):
+ registered_ids = _get_registered_block_ids()
+ valid_zones = {z for z in preset["zones"] if z != "header"}
+ default_zone = "body" if "body" in valid_zones else next(iter(valid_zones))
+
+ for topic in topics:
+ tid = topic.get("id")
role = topic.get("role", "flow")
- if role == "reference" and preset_name == "sidebar-right":
- area = "sidebar"
- elif topic.get("layer") == "conclusion":
- area = "footer"
- else:
- area = "body" if preset_name != "two-column" else "left"
+ # 블록 타입: Kei 확정값
+ block_type = kei_confirmed_blocks.get(tid, "topic-left-right")
- # conclusion → banner-gradient, 그 외 → topic-left-right
- block_type = "banner-gradient" if topic.get("layer") == "conclusion" else "topic-left-right"
+ # 블록 ID 검증: catalog에 없으면 에러 로그 (fallback 없음)
+ if block_type not in registered_ids:
+ logger.error(f"[블록 검증] Kei 확정 블록 '{block_type}'이 catalog에 없음. topic {tid}")
+ block_type = "topic-left-right" # 최소 안전 블록
+
+ # zone 배치: Kei 확정값 → 검증
+ area = kei_confirmed_areas.get(tid, "")
+ if not area or area not in valid_zones:
+ # Kei가 area를 안 줬으면 role에서 결정
+ if role == "reference" and "sidebar" in valid_zones:
+ area = "sidebar"
+ elif topic.get("layer") == "conclusion" and "footer" in valid_zones:
+ area = "footer"
+ else:
+ area = default_zone
+
+ # conclusion 꼭지 → footer 강제
+ if topic.get("layer") == "conclusion" and "footer" in valid_zones:
+ area = "footer"
+
+ # body/sidebar 금지 블록 검증
+ if area in ("body", "left", "right", "hero", "detail") and block_type in BODY_FORBIDDEN_MAP:
+ replacement = BODY_FORBIDDEN_MAP[block_type]
+ if replacement:
+ logger.warning(f"[블록 검증] body 금지 '{block_type}' → '{replacement}'")
+ block_type = replacement
+ else:
+ continue # None이면 삭제
+
+ if area == "sidebar" and block_type in SIDEBAR_FORBIDDEN_BLOCKS:
+ replacement = SIDEBAR_FORBIDDEN_BLOCKS[block_type]
+ if replacement:
+ logger.warning(f"[블록 검증] sidebar 금지 '{block_type}' → '{replacement}'")
+ block_type = replacement
+ else:
+ continue
blocks.append({
"area": area,
"type": block_type,
- "topic_id": topic.get("id", 0),
- "reason": topic.get("title", ""),
+ "topic_id": tid,
+ "purpose": topic.get("purpose", ""),
+ "reason": kei_confirmed_blocks.get(tid, ""),
"size": "medium",
})
- return {
+ # Phase N-2: sidebar에 reference 블록이 있으면 section label 자동 삽입
+ sidebar_blocks = [b for b in blocks if b.get("area") == "sidebar"]
+ if sidebar_blocks:
+ first_sidebar = sidebar_blocks[0]
+ sidebar_topic = next(
+ (t for t in topics if t.get("id") == first_sidebar.get("topic_id")),
+ None,
+ )
+ section_title = ""
+ if sidebar_topic:
+ section_title = sidebar_topic.get("section_title", "")
+ if not section_title:
+ purpose = first_sidebar.get("purpose", "")
+ section_title = {
+ "용어정의": "용어 정의",
+ "근거사례": "참고 자료",
+ }.get(purpose, "")
+
+ if section_title:
+ first_sidebar_idx = next(
+ i for i, b in enumerate(blocks) if b.get("area") == "sidebar"
+ )
+ blocks.insert(first_sidebar_idx, {
+ "area": "sidebar",
+ "type": "divider-text",
+ "topic_id": None,
+ "purpose": "_label",
+ "data": {"text": section_title},
+ "size": "compact",
+ "_is_label": True,
+ })
+ logger.info(f"[N-2] sidebar 섹션 제목 삽입: '{section_title}'")
+
+ # zone별 height_cost 합산 검증
+ overflows = _validate_height_budget(blocks, preset)
+
+ logger.info(
+ f"[레이아웃] 블록 배치 완료: {preset_name}, {len(blocks)}개 블록"
+ + (f", overflow {len(overflows)}건" if overflows else "")
+ )
+
+ result = {
"title": analysis.get("title", "슬라이드"),
"pages": [{
"grid_areas": preset["grid_areas"],
@@ -874,6 +730,9 @@ def _fallback_layout(
"blocks": blocks,
}],
}
+ if overflows:
+ result["overflow"] = overflows
+ return result
# height_cost → px 변환 (결정론적)
@@ -884,31 +743,30 @@ 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 헤더로
+ "section-header-bar": None, # body에서 제거 — header에 이미 slide-title 있음 (J-2)
}
-# xlarge/large → medium/compact 교체 후보
-DOWNGRADE_MAP = {
- "venn-diagram": "card-icon-desc",
- "card-step-vertical": "card-numbered",
- "image-grid-2x2": "image-row-2col",
- "compare-3col-badge": "comparison-2col",
- "card-image-3col": "card-icon-desc",
- "card-tag-image": "card-icon-desc",
- "card-compare-3col": "comparison-2col",
- "card-image-round": "card-icon-desc",
+# Phase M: 블록-zone 적합성 맵
+# sidebar(35% 너비)에서 사용 불가한 블록 → 대체 블록
+SIDEBAR_FORBIDDEN_BLOCKS = {
+ "card-compare-3col": "card-numbered",
+ "card-dark-overlay": "card-numbered",
+ "card-icon-desc": "card-numbered",
+ "card-image-3col": "card-numbered",
+ "card-image-round": "card-numbered",
+ "card-stat-number": "card-numbered",
+ "card-tag-image": "card-numbered",
+ "comparison-2col": "dark-bullet-list",
+ "compare-2col-split": "dark-bullet-list",
+ "compare-pill-pair": "dark-bullet-list",
+ "section-title-with-bg": None,
+ "section-header-bar": None,
+ "topic-center": "topic-left-right",
+ "quote-big-mark": "quote-question",
+ "image-full-caption": "image-row-2col",
}
@@ -932,14 +790,58 @@ def _load_catalog_map_for_height() -> dict[str, str]:
return {}
+def _load_catalog_purpose_fit() -> dict[str, list[str]]:
+ """catalog.yaml에서 id → purpose_fit 매핑을 로드."""
+ catalog_path = Path(__file__).parent.parent / "templates" / "catalog.yaml"
+ if not catalog_path.exists():
+ return {}
+ try:
+ with open(catalog_path, encoding="utf-8") as f:
+ data = yaml.safe_load(f)
+ return {
+ b["id"]: b.get("purpose_fit", [])
+ for b in data.get("blocks", [])
+ }
+ except Exception:
+ return {}
+
+
+def _validate_purpose_fit(blocks: list[dict]) -> int:
+ """각 블록의 purpose_fit을 검증하고, 불일치 시 대체한다.
+
+ Returns:
+ 교체된 블록 수.
+ """
+ purpose_fit_map = _load_catalog_purpose_fit()
+ replaced = 0
+
+ for block in blocks:
+ block_type = block.get("type", "")
+ purpose = block.get("purpose", "")
+ if not block_type or not purpose:
+ continue
+
+ allowed_purposes = purpose_fit_map.get(block_type, [])
+ # purpose_fit이 빈 리스트면 범용 블록 → 검증 스킵
+ if not allowed_purposes:
+ continue
+
+ if purpose not in allowed_purposes:
+ # Kei가 확정한 블록이므로 경고만 출력. 강제 교체 안 함.
+ logger.warning(
+ f"[purpose_fit 검증] '{block_type}'의 purpose_fit={allowed_purposes}에 "
+ f"'{purpose}' 없음 — Kei 확정이므로 유지"
+ )
+
+ return replaced
+
+
def _validate_height_budget(blocks: list[dict], preset: dict) -> list[dict]:
"""zone별 height_cost 합산을 검증한다. (I-9 개정)
금지 블록 교체, pill-pair 단독 검증은 수행하되,
높이 초과 시 블록을 자동 교체하지 않는다.
대신 overflow 정보를 수집하여 반환 → pipeline에서 Kei에게 판단 요청.
- DOWNGRADE_MAP은 Kei API 실패 시 비상용으로만 사용.
-
Returns:
overflow 정보 리스트. 초과 없으면 빈 리스트.
"""
@@ -954,16 +856,55 @@ def _validate_height_budget(blocks: list[dict], preset: dict) -> list[dict]:
zone_blocks[area] = []
zone_blocks[area].append(block)
- # 금지 블록 교체 (body/sidebar/footer에서 사용 불가한 블록)
+ # 금지 블록 처리: 교체 또는 삭제 (J-2: None이면 삭제)
+ blocks_to_remove = []
for block in blocks:
area = block.get("area", "body")
block_type = block.get("type", "")
if area != "header" and block_type in BODY_FORBIDDEN_MAP:
replacement = BODY_FORBIDDEN_MAP[block_type]
- logger.warning(
- f"[금지 블록 교체] {block_type} → {replacement} (area={area})"
- )
- block["type"] = replacement
+ if replacement is None:
+ blocks_to_remove.append(block)
+ logger.warning(
+ f"[금지 블록 삭제] {block_type} (area={area})"
+ )
+ else:
+ block["type"] = replacement
+ logger.warning(
+ f"[금지 블록 교체] {block_type} → {replacement} (area={area})"
+ )
+ for block in blocks_to_remove:
+ blocks.remove(block)
+
+ # 삭제 후 zone_blocks 재구성 (후속 pill-pair/높이 체크에 반영)
+ zone_blocks.clear()
+ for block in blocks:
+ area = block.get("area", "body")
+ if area not in zone_blocks:
+ zone_blocks[area] = []
+ zone_blocks[area].append(block)
+
+ # Phase M: sidebar 블록-zone 적합성 검증 (P-6)
+ for block in blocks:
+ if block.get("area") == "sidebar" and block.get("type") in SIDEBAR_FORBIDDEN_BLOCKS:
+ replacement = SIDEBAR_FORBIDDEN_BLOCKS[block["type"]]
+ if replacement is None:
+ logger.warning(f"[zone 적합성] sidebar에서 {block['type']} 삭제")
+ else:
+ logger.warning(f"[zone 적합성] sidebar: {block['type']} → {replacement}")
+ block["type"] = replacement
+
+ # sidebar 카드 블록 1열 강제 (J-6)
+ CARD_BLOCKS = {
+ "card-tag-image", "card-icon-desc", "card-image-3col",
+ "card-dark-overlay", "card-compare-3col", "card-image-round",
+ "card-stat-number",
+ }
+ for block in blocks:
+ if block.get("area") == "sidebar" and block.get("type") in CARD_BLOCKS:
+ if "data" not in block:
+ block["data"] = {}
+ block["data"]["column_override"] = 1
# compare-pill-pair 단독 사용 금지 (I-7)
COMPARISON_BLOCKS = {"compare-2col-split", "compare-3col-badge", "comparison-2col"}
@@ -977,7 +918,7 @@ def _validate_height_budget(blocks: list[dict], preset: dict) -> list[dict]:
"[pill-pair 단독 금지] compare-pill-pair → comparison-2col"
)
- # 높이 예산 검증 — 초과 시 overflow 정보 수집 (블록 교체 안 함)
+ # 높이 예산 검증 — 초과 시 자동 조치 + overflow 정보 수집
overflows: list[dict] = []
for area, area_blocks in zone_blocks.items():
zone_info = zones.get(area, {})
@@ -989,6 +930,29 @@ def _validate_height_budget(blocks: list[dict], preset: dict) -> list[dict]:
if total <= budget:
continue
+ overflow_px = total - budget
+
+ # footer 초과 자동 조치: banner-gradient의 sub_text 제거로 높이 축소
+ if area == "footer" and overflow_px <= 30:
+ for block in area_blocks:
+ if block.get("type") == "banner-gradient":
+ if "data" not in block:
+ block["data"] = {}
+ block["data"]["_strip_sub_text"] = True
+ logger.info(
+ f"[높이 자동 조치] footer 초과 {overflow_px}px → "
+ f"banner-gradient sub_text 제거"
+ )
+ # sub_text 제거 시 compact(50px)로 줄어들므로 재계산
+ total_after = sum(
+ 50 if (b.get("type") == "banner-gradient" and b.get("data", {}).get("_strip_sub_text"))
+ else _get_block_height(b.get("type", ""))
+ for b in area_blocks
+ )
+ total_after += gap_px * max(0, len(area_blocks) - 1)
+ if total_after <= budget:
+ continue # 조치 후 예산 이내 → overflow 아님
+
logger.warning(
f"[높이 예산 초과] {area}: {total}px > {budget}px. "
f"블록: {[b.get('type') for b in area_blocks]}"
@@ -1013,42 +977,6 @@ def _validate_height_budget(blocks: list[dict], preset: dict) -> list[dict]:
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", "")
- block_height = _get_block_height(block_type)
-
- if block_type in DOWNGRADE_MAP and block_height >= 250:
- replacement = DOWNGRADE_MAP[block_type]
- old_height = block_height
- new_height = _get_block_height(replacement)
-
- block["type"] = replacement
- total = total - old_height + new_height
-
- logger.warning(
- f"[DOWNGRADE 비상] {block_type}({old_height}px) → "
- f"{replacement}({new_height}px). 잔여: {total}px/{budget}px"
- )
-
- if total <= budget:
- break
-
-
def _parse_json(text: str) -> dict[str, Any] | None:
"""텍스트에서 JSON을 추출한다.
diff --git a/src/kei_client.py b/src/kei_client.py
index e6d32b9..b07e52d 100644
--- a/src/kei_client.py
+++ b/src/kei_client.py
@@ -1,7 +1,7 @@
"""DA-12: 1단계 — Kei 실장 (꼭지 추출 + 분석).
-1차: Kei API를 통해 Kei persona가 사고하여 꼭지를 추출한다.
-fallback: Kei API 실패 시 Anthropic API 직접 호출.
+Kei API를 통해 Kei persona가 사고하여 꼭지를 추출한다.
+Kei API는 필수. fallback 없음. 성공할 때까지 무한 재시도.
"""
from __future__ import annotations
@@ -28,13 +28,20 @@ KEI_PROMPT = (
"- 독립적으로 참조되는 정보(용어 정의, 부록)가 있는가?\n"
"- info_structure 필드에 기술.\n\n"
"## 3단계: 슬라이드 스토리라인 설계\n"
- "핵심 메시지를 전달하기 위한 **흐름**을 설계해줘. 각 위치에 **목적(purpose)**을 부여:\n"
- "- 문제제기: 왜 이 주제가 중요한가? 현재 무엇이 잘못되고 있는가?\n"
- "- 근거사례: 문제의 근거, 사례, 증거 (출처 포함)\n"
- "- 핵심전달: 그래서 사실은 이거다. 핵심 내용 전달.\n"
- "- 용어정의: 사용된 용어를 구체적으로 설명 (보조 참조, sidebar 배치)\n"
- "- 결론강조: 핵심 메시지 강조. 슬라이드 하단.\n"
- "- 구조시각화: 관계도, 프로세스 등 시각화가 필요한 경우\n\n"
+ "핵심 메시지를 전달하기 위한 **흐름**을 설계해줘.\n"
+ "각 꼭지에 purpose를 부여하고, topics 배열에 기록.\n\n"
+ "## 4단계: 페이지 구조 판단 (비중 시스템)\n"
+ "콘텐츠를 분석하여 이 페이지의 **구조와 비중**을 판단하라:\n\n"
+ "- **본심**: 이 페이지가 말하려는 핵심. 가장 큰 공간을 차지해야 함.\n"
+ " 비교라면 비교표, 관계라면 관계도, 프로세스라면 흐름도로 구조화.\n"
+ " 비교 구조일 때 비교 목적(왜 비교하는가)을 summary에 명시.\n"
+ "- **배경**: 본심을 이해하기 위한 도입/배경. 간결하게. 2-3줄이면 충분.\n"
+ "- **첨부**: 본심을 보조하는 참조 정보 (용어 정의 등). sidebar 배치.\n"
+ " role: 'reference'로 표시. 본문 흐름을 방해하지 않도록.\n"
+ "- **결론**: 절대 잊으면 안 되는 핵심 한 줄. footer.\n\n"
+ "각 역할에 해당하는 topic_ids와 **공간 비중(weight, 합계 1.0)**을 결정하라.\n"
+ "**콘텐츠에 따라 비중은 매번 달라진다. 고정값이 아니다.**\n"
+ "page_structure 필드에 기록.\n\n"
"## 원본 텍스트 보존 원칙\n"
"- 원본의 논리 흐름과 정보를 빠뜨리지 마라\n"
"- 원본 텍스트는 최대한 보존. 약간의 편집만.\n"
@@ -54,12 +61,18 @@ KEI_PROMPT = (
'"core_message": "이 슬라이드의 핵심 메시지 한 줄", '
'"total_pages": 1, '
'"info_structure": "정보 구조 설명", '
+ '"page_structure": {'
+ '"본심": {"topic_ids": [2, 3], "weight": 0.60}, '
+ '"배경": {"topic_ids": [1], "weight": 0.15}, '
+ '"첨부": {"topic_ids": [4], "weight": 0.15}, '
+ '"결론": {"topic_ids": [5], "weight": 0.10}}, '
'"topics": ['
'{"id": 1, "title": "꼭지 제목", "summary": "요약", '
'"purpose": "문제제기|근거사례|핵심전달|용어정의|결론강조|구조시각화", '
'"source_hint": "원본에서 이 위치에 가져올 텍스트 범위 설명", '
'"layer": "intro|core|supporting|conclusion", '
'"role": "flow|reference", '
+ '"section_title": "sidebar에 표시할 섹션 제목 (reference일 때만. 예: 용어 정의, 참고 자료)", '
'"emphasis": true, "direction": "vertical|horizontal|flexible", '
'"content_type": "text|image|table|mixed", '
'"detail_target": false, "page": 1}], '
@@ -73,8 +86,7 @@ KEI_PROMPT = (
async def classify_content(content: str) -> dict[str, Any] | None:
"""1단계: Kei API를 통해 꼭지를 추출하고 분석한다.
- Kei API만 사용. Sonnet fallback 없음.
- Kei API 실패 시 None 반환 → pipeline.py에서 manual_classify() 안전망.
+ Kei API만 사용. fallback 없음. 실패 시 None → pipeline에서 에러.
"""
result = await _call_kei_api(content)
if result:
@@ -84,7 +96,7 @@ async def classify_content(content: str) -> dict[str, Any] | None:
)
return result
- logger.warning("[Kei API] 꼭지 추출 실패. manual_classify로 안전망 적용.")
+ logger.error("[Kei API] 꼭지 추출 실패. Kei API(localhost:8000) 확인 필요.")
return None
@@ -127,9 +139,11 @@ async def refine_concepts(
"""1단계-B: 각 꼭지의 컨셉을 구체화한다.
1단계-A 결과(topics)를 받아서, 각 꼭지의 관계 성격/표현 방법/원본 데이터를 판단.
- Kei API만 사용. 실패 시 1단계-A 결과를 그대로 반환 (pipeline 안 멈춤).
+ Kei API만 사용. fallback 없음. 성공할 때까지 재시도.
1회 호출로 모든 꼭지를 한꺼번에 처리.
"""
+ import asyncio
+
topics = analysis.get("topics", [])
if not topics:
return analysis
@@ -150,48 +164,62 @@ async def refine_concepts(
)
kei_url = getattr(settings, "kei_api_url", "http://localhost:8000")
+ RETRY_INTERVAL = 10
+ attempt = 0
- 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-refine",
- "mode_hint": "chat",
- },
- timeout=None,
- ) as response:
- if response.status_code != 200:
- logger.warning(f"[1단계-B] Kei API HTTP {response.status_code}")
- return analysis
+ while True:
+ attempt += 1
+ 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-refine",
+ "mode_hint": "chat",
+ },
+ timeout=None,
+ ) as response:
+ if response.status_code != 200:
+ logger.warning(f"[1단계-B] Kei API HTTP {response.status_code} (시도 {attempt})")
+ await asyncio.sleep(RETRY_INTERVAL)
+ continue
- full_text = await stream_sse_tokens(response)
+ full_text = await stream_sse_tokens(response)
- if not full_text:
- logger.warning("[1단계-B] 응답 텍스트 없음. 1단계-A 결과 유지.")
- return analysis
+ if not full_text:
+ logger.warning(f"[1단계-B] 응답 텍스트 없음 (시도 {attempt})")
+ await asyncio.sleep(RETRY_INTERVAL)
+ continue
- result = _parse_json(full_text)
- if result and "concepts" in result:
- # topics에 concept 정보 병합
- concept_map = {c.get("topic_id"): c for c in result["concepts"]}
- for topic in topics:
- concept = concept_map.get(topic.get("id"))
- if concept:
- topic["relation_type"] = concept.get("relation_type", "")
- topic["expression_hint"] = concept.get("expression_hint", "")
- topic["source_data"] = concept.get("source_data", "")
+ result = _parse_json(full_text)
+ if result and "concepts" in result:
+ # topics에 concept 정보 병합
+ # Kei가 topic_id 또는 id로 응답할 수 있으므로 양쪽 다 체크
+ concept_map = {}
+ for c in result["concepts"]:
+ tid = c.get("topic_id") or c.get("id")
+ if tid is not None:
+ concept_map[tid] = c
+ for topic in topics:
+ concept = concept_map.get(topic.get("id"))
+ if concept:
+ topic["relation_type"] = concept.get("relation_type", "")
+ topic["expression_hint"] = concept.get("expression_hint", "")
+ topic["source_data"] = concept.get("source_data", "")
- logger.info(f"[1단계-B] 컨셉 구체화 완료: {len(result['concepts'])}개")
- else:
- logger.warning(f"[1단계-B] JSON 파싱 실패. 1단계-A 결과 유지. 텍스트: {full_text[:200]}")
+ logger.info(f"[1단계-B] 컨셉 구체화 완료: {len(result['concepts'])}개")
+ return analysis
+ else:
+ logger.warning(f"[1단계-B] JSON 파싱 실패 (시도 {attempt}): {full_text[:200]}")
+ await asyncio.sleep(RETRY_INTERVAL)
+ continue
- except Exception as e:
- logger.warning(f"[1단계-B] Kei API 실패: {e}. 1단계-A 결과 유지.")
-
- return analysis
+ except Exception as e:
+ logger.warning(f"[1단계-B] Kei API 실패 (시도 {attempt}): {e}")
+ await asyncio.sleep(RETRY_INTERVAL)
+ continue
async def _call_kei_api(content: str) -> dict[str, Any] | None:
@@ -234,6 +262,156 @@ async def _call_kei_api(content: str) -> dict[str, Any] | None:
+# ──────────────────────────────────────
+# J-7: Kei 최종 검수
+# ──────────────────────────────────────
+
+KEI_REVIEW_PROMPT = """당신은 11년 경력의 기획 실장이다. 디자인 팀장이 조립한 슬라이드를 최종 검수한다.
+
+## 검수 관점
+1. 핵심 메시지(core_message)가 시각적으로 명확히 전달되는가?
+2. 콘텐츠 흐름이 블록 배치와 일치하는가?
+3. 각 블록이 해당 꼭지의 purpose에 적합한가?
+4. 중요한 내용이 빠지거나 과도하게 축소되지 않았는가?
+5. 높이 초과: 각 zone의 블록+텍스트가 예산을 초과하는가?
+ - 텍스트 축약으로 해결 가능 → shrink
+ - 콘텐츠가 본질적으로 큼 → overflow_detected
+6. **핵심전달이 body에서 가장 큰 시각적 비중을 차지하는가?**
+ - 핵심전달 블록이 도입부(문제제기+근거사례)보다 작으면 → rewrite로 비중 조정
+7. **문제제기가 간결한가? (100자 이내)**
+ - 초과 시 → shrink (target_ratio: 0.5)
+8. **용어정의가 sidebar에 있는가?**
+ - body에 있으면 → 구조 문제 지적 (issues에 명시)
+9. **핵심전달 블록이 화면 안에 보이는가?**
+ - 잘리면 → overflow_detected
+
+## 조정 action
+- expand: 텍스트 늘림 (target_ratio, 예: 1.3)
+- shrink: 텍스트 줄임 (target_ratio, 예: 0.7)
+- rewrite: 텍스트 재작성 (detail에 방향)
+- overflow_detected: 높이 초과, 콘텐츠 판단 필요 (zone과 블록 명시)
+
+## 출력 (JSON만. 설명 없이.)
+{"needs_adjustment": true/false, "issues": ["이슈1"], "adjustments": [{"block_area": "...", "action": "expand|shrink|rewrite|overflow_detected", "target_ratio": 1.3, "detail": "..."}]}
+"""
+
+
+async def call_kei_final_review(
+ html: str,
+ block_summary: list[str],
+ zone_budget_text: str,
+ overflow_hint_text: str,
+ analysis: dict[str, Any] | None = None,
+ screenshot_b64: str | None = None,
+) -> dict[str, Any] | None:
+ """Phase N-4: Kei(Opus)가 스크린샷을 보고 최종 검수한다.
+
+ 스크린샷이 있으면: Anthropic API 직접 호출 (Opus + 멀티모달)
+ 스크린샷이 없으면: Kei API 경유 (텍스트 기반)
+ 어느 경로든 Kei(Opus)가 판단. Sonnet 절대 금지.
+ """
+ import anthropic
+
+ core_message = analysis.get("core_message", "") if analysis else ""
+ topics_summary = ""
+ if analysis:
+ topics_summary = "\n".join(
+ f"- 꼭지 {t.get('id')}: {t.get('title', '')} [{t.get('purpose', '')}]"
+ for t in analysis.get("topics", [])
+ )
+
+ review_text = (
+ f"## 핵심 메시지\n{core_message}\n\n"
+ f"## 꼭지 목록\n{topics_summary}\n\n"
+ f"## 블록별 데이터 양\n" + "\n".join(block_summary) +
+ zone_budget_text +
+ overflow_hint_text +
+ f"\n\n위 슬라이드를 검수하고 조정이 필요한지 판단해. JSON만."
+ )
+
+ # 스크린샷이 있으면: Opus 직접 호출 + 이미지 전달
+ if screenshot_b64:
+ try:
+ client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
+ response = await client.messages.create(
+ model="claude-opus-4-0-20250514",
+ max_tokens=4096,
+ system=KEI_REVIEW_PROMPT,
+ messages=[{
+ "role": "user",
+ "content": [
+ {
+ "type": "image",
+ "source": {
+ "type": "base64",
+ "media_type": "image/png",
+ "data": screenshot_b64,
+ },
+ },
+ {
+ "type": "text",
+ "text": review_text,
+ },
+ ],
+ }],
+ )
+
+ result_text = response.content[0].text
+ result = _parse_json(result_text)
+ if result and "needs_adjustment" in result:
+ logger.info(
+ f"[Kei 최종 검수] 스크린샷 기반, needs_adjustment={result['needs_adjustment']}"
+ )
+ return result
+ logger.warning("[Kei 최종 검수] 스크린샷 기반 JSON 파싱 실패")
+ return None
+
+ except Exception as e:
+ logger.warning(f"Kei 최종 검수 (스크린샷) 실패: {e}")
+ return None
+
+ # 스크린샷 없으면: Kei API 경유 (텍스트 기반)
+ kei_url = getattr(settings, "kei_api_url", "http://localhost:8000")
+ prompt = (
+ KEI_REVIEW_PROMPT + "\n\n" + review_text +
+ f"\n\n## 조립 HTML (요약)\n{html[:3000]}"
+ )
+
+ 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-final-review",
+ "mode_hint": "chat",
+ },
+ timeout=None,
+ ) as response:
+ if response.status_code != 200:
+ logger.warning(f"Kei 최종 검수 HTTP {response.status_code}")
+ return None
+ full_text = await stream_sse_tokens(response)
+
+ if full_text:
+ result = _parse_json(full_text)
+ if result and "needs_adjustment" in result:
+ logger.info(
+ f"[Kei 최종 검수] 텍스트 기반, needs_adjustment={result['needs_adjustment']}"
+ )
+ return result
+ logger.warning("[Kei 최종 검수] JSON 파싱 실패")
+ return None
+
+ logger.warning("Kei 최종 검수 텍스트 추출 실패")
+ return None
+
+ except Exception as e:
+ logger.warning(f"Kei 최종 검수 실패: {e}")
+ return None
+
+
# ──────────────────────────────────────
# I-9: Kei 넘침 판단 호출
# ──────────────────────────────────────
@@ -266,7 +444,7 @@ async def call_kei_overflow_judgment(
"""Kei API에 넘침 상황을 전달하고 판단을 받는다.
반드시 Kei API 경유. Anthropic 직접 호출 절대 금지.
- fallback: None 반환 → pipeline에서 DOWNGRADE 비상 작동.
+ 실패 시 None → pipeline에서 무한 재시도.
"""
kei_url = getattr(settings, "kei_api_url", "http://localhost:8000")
@@ -370,29 +548,4 @@ def _parse_json(text: str) -> dict[str, Any] | None:
return None
-def manual_classify(content: str) -> dict[str, Any]:
- """분류 실패 시 기본 구조 fallback."""
- return {
- "title": "슬라이드",
- "core_message": "",
- "total_pages": 1,
- "info_structure": "",
- "topics": [
- {
- "id": 1,
- "title": "핵심 내용",
- "summary": content[:100],
- "purpose": "핵심전달",
- "source_hint": "",
- "layer": "core",
- "role": "flow",
- "emphasis": False,
- "direction": "flexible",
- "content_type": "text",
- "detail_target": False,
- "page": 1,
- },
- ],
- "images": [],
- "tables": [],
- }
+# manual_classify 삭제됨. Kei API는 필수. fallback 없음.
diff --git a/src/pipeline.py b/src/pipeline.py
index 72a3758..812cd55 100644
--- a/src/pipeline.py
+++ b/src/pipeline.py
@@ -11,19 +11,60 @@ from __future__ import annotations
import json
import logging
import re
+import time
+from pathlib import Path
from typing import Any, AsyncIterator
import anthropic
-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.kei_client import classify_content, refine_concepts, call_kei_overflow_judgment, call_kei_final_review
+from src.design_director import create_layout_concept, LAYOUT_PRESETS, select_preset
from src.content_editor import fill_content
from src.renderer import render_slide
from src.image_utils import get_image_sizes, embed_images
+from src.space_allocator import calculate_container_specs, finalize_block_specs, find_container_for_topic, calculate_trim_chars
+from src.slide_measurer import measure_rendered_heights, format_measurement_for_kei, capture_slide_screenshot
from src.config import settings
logger = logging.getLogger(__name__)
+# Kei API 재시도 간격(초). 제한 없음 — 성공할 때까지 무한 재시도.
+KEI_RETRY_INTERVAL = 10
+
+
+async def _retry_kei(fn, *args, **kwargs):
+ """Kei API 호출을 성공할 때까지 무한 재시도한다.
+
+ Kei API는 필수 인프라. fallback 없음. 제한 없음.
+ 10분이든 20분이든 Kei가 응답할 때까지 기다린다.
+ """
+ import asyncio
+ attempt = 0
+ while True:
+ attempt += 1
+ result = await fn(*args, **kwargs)
+ if result is not None:
+ if attempt > 1:
+ logger.info(f"[Kei 재시도] {fn.__name__} 성공 ({attempt}번째 시도)")
+ return result
+ logger.warning(
+ f"[Kei 재시도] {fn.__name__} 실패 (시도 {attempt}). "
+ f"{KEI_RETRY_INTERVAL}초 후 재시도..."
+ )
+ await asyncio.sleep(KEI_RETRY_INTERVAL)
+
+
+def _save_step(run_dir: Path, filename: str, data: Any) -> None:
+ """스텝 결과를 JSON 또는 HTML로 저장한다. (K-1)"""
+ run_dir.mkdir(parents=True, exist_ok=True)
+ filepath = run_dir / filename
+ if filename.endswith(".html"):
+ filepath.write_text(data, encoding="utf-8")
+ else:
+ with open(filepath, "w", encoding="utf-8") as f:
+ json.dump(data, f, ensure_ascii=False, indent=2)
+ logger.info(f"[중간 산출물] {filename} 저장 → {run_dir.name}/")
+
async def generate_slide(
content: str,
@@ -35,6 +76,10 @@ async def generate_slide(
Yields:
SSE 이벤트: progress / result / error
"""
+ # K-1: 중간 산출물 저장용 디렉토리
+ run_id = str(int(time.time() * 1000))
+ run_dir = Path("data/runs") / run_id
+
try:
# 1단계: Kei 실장 — 꼭지 추출 + 분석
yield {"event": "progress", "data": "1/5 Kei 실장이 꼭지를 추출 중..."}
@@ -42,18 +87,24 @@ async def generate_slide(
if manual_layout:
analysis = manual_layout
else:
- analysis = await classify_content(content)
- if analysis is None:
- analysis = manual_classify(content)
+ analysis = await _retry_kei(classify_content, content)
+ # _retry_kei는 무한 재시도. None이 올 수 없다.
topic_count = len(analysis.get("topics", []))
page_count = analysis.get("total_pages", 1)
logger.info(f"1단계-A 완료: {topic_count}개 꼭지, {page_count}페이지")
+ _save_step(run_dir, "step1_analysis.json", analysis)
# 1단계-B: 각 꼭지 컨셉 구체화
yield {"event": "progress", "data": "1.5/5 Kei 실장이 각 꼭지의 컨셉을 구체화 중..."}
analysis = await refine_concepts(content, analysis)
logger.info("1단계-B 완료: 컨셉 구체화")
+ _save_step(run_dir, "step1b_concepts.json", {
+ "concepts": [
+ {k: t.get(k) for k in ("id", "title", "purpose", "relation_type", "expression_hint", "source_data")}
+ for t in analysis.get("topics", [])
+ ]
+ })
# I-6: 슬라이드 제목 ↔ 첫 꼭지 제목 중복 검증
from difflib import SequenceMatcher
@@ -75,10 +126,34 @@ async def generate_slide(
analysis["image_sizes"] = image_sizes
logger.info(f"이미지 측정: {len(image_sizes)}개")
- # 2단계: 디자인 팀장 — Step A(프리셋) + Step B(블록 매핑)
+ # ★ Phase O-1: 컨테이너 스펙 계산 (Kei 비중 → px 확정)
+ preset_name = select_preset(analysis)
+ preset = LAYOUT_PRESETS.get(preset_name, {})
+ page_struct = analysis.get("page_structure", {})
+
+ container_specs = calculate_container_specs(
+ page_structure=page_struct,
+ topics=analysis.get("topics", []),
+ preset=preset,
+ slide_width=settings.slide_width,
+ slide_height=settings.slide_height,
+ )
+ _save_step(run_dir, "step1c_containers.json", {
+ role: {
+ "height_px": spec.height_px,
+ "width_px": spec.width_px,
+ "max_height_cost": spec.max_height_cost,
+ "topic_ids": spec.topic_ids,
+ "weight": spec.weight,
+ "block_constraints": spec.block_constraints,
+ }
+ for role, spec in container_specs.items()
+ })
+
+ # 2단계: 디자인 팀장 — Step A(프리셋) + Step A-2(Kei 블록 확정) + Step B(zone 배치)
yield {"event": "progress", "data": "2/5 디자인 팀장이 레이아웃을 설계 중..."}
- layout_concept = await create_layout_concept(content, analysis)
+ layout_concept = await create_layout_concept(content, analysis, container_specs=container_specs)
total_blocks = sum(
len(p.get("blocks", [])) for p in layout_concept.get("pages", [])
@@ -87,12 +162,59 @@ async def generate_slide(
f"2단계 완료: {len(layout_concept.get('pages', []))}페이지, "
f"{total_blocks}개 블록"
)
+ _save_step(run_dir, "step2_layout.json", {
+ "preset": layout_concept.get("pages", [{}])[0].get("grid_areas", ""),
+ "blocks": [
+ {
+ "area": b.get("area"), "type": b.get("type"),
+ "topic_id": b.get("topic_id"), "purpose": b.get("purpose"),
+ "reason": b.get("reason", ""), "size": b.get("size", ""),
+ }
+ for p in layout_concept.get("pages", [])
+ for b in p.get("blocks", [])
+ ],
+ "overflow": layout_concept.get("overflow", []),
+ })
+
+ # ★ Phase O-3: 블록 스펙 확정 (컨테이너 크기 → 항목수/글자수/폰트)
+ for page in layout_concept.get("pages", []):
+ finalize_block_specs(page.get("blocks", []), container_specs)
+ # 컨테이너 스펙을 layout_concept에 저장 (렌더러에서 사용)
+ layout_concept["_container_specs"] = container_specs
+
+ _save_step(run_dir, "step2c_block_specs.json", {
+ "blocks": [
+ {
+ "type": b.get("type"), "topic_id": b.get("topic_id"),
+ "area": b.get("area"),
+ "_container_height_px": b.get("_container_height_px"),
+ "_max_items": b.get("_max_items"),
+ "_max_chars_per_item": b.get("_max_chars_per_item"),
+ "_max_chars_total": b.get("_max_chars_total"),
+ "_font_size_px": b.get("_font_size_px"),
+ }
+ for p in layout_concept.get("pages", [])
+ for b in p.get("blocks", [])
+ ]
+ })
# 3단계: 텍스트 편집자 — 텍스트 정리
yield {"event": "progress", "data": "3/5 텍스트 편집자가 핵심을 정리 중..."}
layout_concept = await fill_content(content, layout_concept, analysis)
logger.info("3단계 완료: 텍스트 정리")
+ _save_step(run_dir, "step3_filled_blocks.json", {
+ "blocks": [
+ {
+ "area": b.get("area"), "type": b.get("type"),
+ "topic_id": b.get("topic_id"), "purpose": b.get("purpose"),
+ "data": b.get("data", {}),
+ "char_count": len(json.dumps(b.get("data", {}), ensure_ascii=False)),
+ }
+ for p in layout_concept.get("pages", [])
+ for b in p.get("blocks", [])
+ ]
+ })
# 4단계: 디자인 실무자 — 디자인 조정 + HTML 조립
yield {"event": "progress", "data": "4/5 디자인 실무자가 슬라이드를 조립 중..."}
@@ -100,14 +222,117 @@ async def generate_slide(
layout_concept = await _adjust_design(layout_concept, analysis)
html = render_slide(layout_concept)
logger.info("4단계 완료: HTML 조립")
+ _save_step(run_dir, "step4_css_adjustment.json", {
+ "area_styles": layout_concept.get("pages", [{}])[0].get("area_styles", {})
+ })
+ _save_step(run_dir, "step4_rendered.html", html)
- # 5단계: 디자인 팀장 — 전체 재검토 (최대 MAX_REVIEW_ROUNDS회)
- MAX_REVIEW_ROUNDS = 2 # 무한 루프 방지 — 최대 재조정 횟수
- yield {"event": "progress", "data": "5/5 디자인 팀장이 전체 균형을 검토 중..."}
+ # Phase L: 렌더링 측정 + 피드백 루프 (최대 3회)
+ import asyncio
+ MAX_MEASURE_ROUNDS = 3
+ measurement = None
- for review_round in range(MAX_REVIEW_ROUNDS):
+ for measure_round in range(MAX_MEASURE_ROUNDS):
+ measurement = await asyncio.to_thread(measure_rendered_heights, html)
+ _save_step(run_dir, f"step4_measurement_round{measure_round + 1}.json", measurement)
+
+ # overflow 감지 — zone + container 양쪽 체크
+ has_overflow = False
+ for zone_name, zone_data in measurement.get("zones", {}).items():
+ if zone_data.get("overflowed"):
+ has_overflow = True
+ break
+ # Phase O: container 레벨 overflow도 체크
+ for cont_name, cont_data in measurement.get("containers", {}).items():
+ if cont_data.get("overflowed"):
+ has_overflow = True
+ logger.warning(
+ f"[측정] container-{cont_name}: "
+ f"scroll={cont_data.get('scrollHeight')}px > "
+ f"allocated={cont_data.get('allocatedHeight')}px "
+ f"(+{cont_data.get('excess_px')}px)"
+ )
+ break
+
+ if not has_overflow:
+ logger.info(f"[측정] 모든 zone/container 정상 (round {measure_round + 1})")
+ break
+
+ logger.warning(f"[측정] overflow 감지 (round {measure_round + 1})")
+
+ # 수학적 축약량 계산 → 편집자 재호출
+ adjusted = False
+ for zone_name, zone_data in measurement.get("zones", {}).items():
+ if not zone_data.get("overflowed"):
+ continue
+ excess = zone_data.get("excess_px", 0)
+ zone_info = preset.get("zones", {}).get(zone_name, {})
+ width_px = int(settings.slide_width * zone_info.get("width_pct", 100) / 100 * 0.85)
+
+ # Phase O: overflow 블록의 _max_chars_total 축소
+ for block_m in zone_data.get("blocks", []):
+ if block_m.get("overflowed"):
+ trim_chars = calculate_trim_chars(
+ block_m.get("excess_px", excess),
+ width_px,
+ )
+ for page in layout_concept.get("pages", []):
+ for block in page.get("blocks", []):
+ if block.get("area") == zone_name:
+ current_max = block.get("_max_chars_total", 400)
+ block["_max_chars_total"] = max(20, current_max - trim_chars)
+ if "data" in block:
+ del block["data"]
+ adjusted = True
+ logger.info(
+ f"[측정 조정] {zone_name}/{block_m.get('block_type')}: "
+ f"{block_m.get('excess_px')}px 초과 → "
+ f"_max_chars_total {current_max}→{block['_max_chars_total']} ({trim_chars}자 축약)"
+ )
+ break
+
+ if not adjusted:
+ logger.info("[측정] 조정 대상 없음, 현재 결과 확정")
+ break
+
+ # 편집자 재호출 → 재렌더링
+ layout_concept = await fill_content(content, layout_concept, analysis)
+ layout_concept = await _adjust_design(layout_concept, analysis)
+ html = render_slide(layout_concept)
+ logger.info(f"[측정] round {measure_round + 1} 재렌더링 완료")
+
+ # 측정 결과 텍스트 (Kei 검수에 전달)
+ measurement_text = format_measurement_for_kei(measurement) if measurement else ""
+
+ # Phase N-4: 5단계 — Kei 실장 최종 검수 (스크린샷 기반, 최대 1회)
+ # overflow 없으면 skip (시간 절약)
+ has_any_overflow = False
+ if measurement:
+ for zone_data in measurement.get("zones", {}).values():
+ if zone_data.get("overflowed"):
+ has_any_overflow = True
+ break
+ if measurement.get("slide", {}).get("overflowed"):
+ has_any_overflow = True
+
+ MAX_REVIEW_ROUNDS = 1
+ screenshot_b64 = None
+
+ if not has_any_overflow:
+ logger.info("5단계 skip: overflow 없음. 검수 불필요.")
+ else:
+ yield {"event": "progress", "data": "5/5 Kei 실장이 최종 검수 중..."}
+
+ # 스크린샷 캡처 (Selenium)
+ screenshot_b64 = await asyncio.to_thread(capture_slide_screenshot, html)
+ if screenshot_b64:
+ _save_step(run_dir, "step5_screenshot.txt", f"base64 PNG, {len(screenshot_b64)} chars")
+ logger.info("[5단계] 스크린샷 캡처 완료 → Kei에게 전달")
+
+ for review_round in range(MAX_REVIEW_ROUNDS if has_any_overflow else 0):
review_result = await _review_balance(
- html, layout_concept, content, analysis
+ html, layout_concept, content, analysis, measurement_text,
+ screenshot_b64=screenshot_b64,
)
if not review_result or not review_result.get("needs_adjustment"):
@@ -122,6 +347,7 @@ async def generate_slide(
f"5단계 ({review_round + 1}/{MAX_REVIEW_ROUNDS}): "
f"조정 필요 — {issues}"
)
+ _save_step(run_dir, f"step5_review_round{review_round + 1}.json", review_result)
# overflow_detected가 있으면 Kei에게 판단 요청 (Sonnet은 감지만, 판단은 Kei)
overflow_adjs = [
@@ -137,14 +363,12 @@ async def generate_slide(
)
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(
+ # 넘침 판단도 Kei 필수 — 성공할 때까지 무한 재시도
+ kei_judgment = await _retry_kei(
+ call_kei_overflow_judgment, overflow_context, content, analysis
+ )
+ _convert_kei_judgment(review_result, kei_judgment)
+ logger.info(
f"[Kei 넘침 판단] decision={kei_judgment.get('decision')}"
)
@@ -164,8 +388,9 @@ async def generate_slide(
html = embed_images(html, base_path)
logger.info("이미지 base64 삽입 완료")
+ _save_step(run_dir, "final.html", html)
yield {"event": "result", "data": html}
- logger.info(f"슬라이드 생성 완료: {len(layout_concept.get('pages', []))}페이지")
+ logger.info(f"슬라이드 생성 완료: {len(layout_concept.get('pages', []))}페이지, run={run_id}")
except Exception as e:
logger.exception(f"파이프라인 오류: {e}")
@@ -279,18 +504,18 @@ async def _review_balance(
layout_concept: dict[str, Any],
content: str,
analysis: dict[str, Any] | None = None,
+ measurement_text: str = "",
+ screenshot_b64: str | None = None,
) -> dict[str, Any] | None:
- """5단계: 디자인 팀장이 조립 결과를 재검토한다.
+ """5단계: Kei 실장이 조립 결과를 최종 검수한다. (J-7 + Phase L)
- HTML 코드 기반으로 구조적 점검 + 높이 넘침 감지:
- - 빈 블록 감지
- - 블록 간 채움 비율 불균형
- - 이미지/표 크기 적절성
- - 높이 초과 감지 → overflow_detected (Kei 판단 필요)
+ Kei가 콘텐츠 관점 + 실제 렌더링 측정 결과 기반으로 검수:
+ - 핵심 메시지 전달 여부
+ - 콘텐츠 흐름 ↔ 블록 배치 일치
+ - 실제 px 기반 높이/비중 검증 (Phase L)
+ - 중요 내용 누락/축소 여부
"""
try:
- client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
-
# 블록별 텍스트 양 요약
block_summary = []
for page in layout_concept.get("pages", []):
@@ -329,51 +554,16 @@ async def _review_balance(
+ "\n".join(hint_lines)
)
- system = (
- "당신은 디자인 팀장이다. 조립 결과(HTML)를 검토하여 균형과 높이 제약을 점검한다.\n\n"
- "## 점검 항목\n"
- "1. 빈 블록: 데이터가 없거나 극히 적은 블록\n"
- "2. 채움 불균형: 한 블록은 빽빽하고 다른 블록은 비어있음\n"
- "3. 이미지/표: 너무 작거나 큰 것은 없는지\n"
- "4. 전체 정보량: 한 페이지에 너무 많거나 적은지\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"
- "- overflow_detected: 높이 초과로 콘텐츠 판단 필요. 해당 zone과 초과 블록을 detail에 명시.\n\n"
- "## 출력 형식 (JSON만)\n"
- '{"needs_adjustment": true/false, '
- '"issues": ["이슈1", "이슈2"], '
- '"adjustments": [{"block_area": "...", "action": "expand|shrink|rewrite|overflow_detected", '
- '"target_ratio": 1.3, "detail": "..."}]}'
- )
+ # Phase L: 렌더링 측정 결과를 overflow_hint에 추가 (실제 px 기반)
+ if measurement_text:
+ overflow_hint_text += f"\n\n{measurement_text}"
- user_prompt = (
- 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"
- f"위 HTML과 데이터를 보고 조정이 필요한지 판단해. JSON으로 답해."
+ # Kei로 최종 검수 (Sonnet 절대 금지, 스크린샷 있으면 이미지 기반)
+ return await call_kei_final_review(
+ html, block_summary, zone_budget_text, overflow_hint_text, analysis,
+ screenshot_b64=screenshot_b64,
)
- response = await client.messages.create(
- model="claude-sonnet-4-20250514",
- max_tokens=1024,
- system=system,
- messages=[{"role": "user", "content": user_prompt}],
- )
-
- result_text = response.content[0].text
- return _parse_json(result_text)
-
except Exception as e:
logger.warning(f"재검토 실패: {e}")
return None
diff --git a/src/renderer.py b/src/renderer.py
index 773a2f5..063759d 100644
--- a/src/renderer.py
+++ b/src/renderer.py
@@ -158,31 +158,89 @@ def _preprocess_svg_data(block_type: str, block_data: dict[str, Any]) -> dict[st
return block_data
-def _group_blocks_by_area(blocks: list[dict[str, Any]]) -> list[dict[str, Any]]:
- """같은 area의 블록들을 하나로 그룹핑한다.
+def _group_blocks_by_area(
+ blocks: list[dict[str, Any]],
+ container_specs: dict | None = None,
+) -> list[dict[str, Any]]:
+ """Phase O: 같은 area의 블록들을 비중 기반 컨테이너로 그룹핑한다.
- CSS Grid에서 같은 area에 여러 div가 있으면 겹치므로,
- 같은 area의 블록 HTML을 합쳐서 하나의 div로 만든다.
+ container_specs가 있으면 body zone 안에 역할별 고정 높이 컨테이너를 생성.
"""
grouped = OrderedDict()
for block in blocks:
area = block["area"]
if area not in grouped:
- grouped[area] = {"area": area, "htmls": []}
- grouped[area]["htmls"].append(block["html"])
+ grouped[area] = {"area": area, "blocks": []}
+ grouped[area]["blocks"].append(block)
result = []
for area, data in grouped.items():
- if len(data["htmls"]) == 1:
- html = data["htmls"][0]
+ block_list = data["blocks"]
+
+ # Phase O: body zone에 컨테이너 스펙 적용
+ if container_specs and area in ("body", "left", "right", "hero", "detail"):
+ container_htmls = []
+ assigned_ids = set()
+
+ role_order = ["배경", "본심"]
+ for role in role_order:
+ spec = container_specs.get(role)
+ if not spec or spec.zone != area:
+ continue
+
+ # 이 역할에 속하는 블록 찾기 (topic_id로 매칭)
+ role_blocks = [
+ b for b in block_list
+ if b.get("_topic_id") in spec.topic_ids
+ and id(b) not in assigned_ids
+ ]
+
+ # topic_id 매칭 안 되면 순서로 매칭
+ if not role_blocks:
+ for b in block_list:
+ if id(b) not in assigned_ids:
+ role_blocks.append(b)
+ if len(role_blocks) >= len(spec.topic_ids):
+ break
+
+ for b in role_blocks:
+ assigned_ids.add(id(b))
+
+ if not role_blocks:
+ continue
+
+ inner_html = "\n".join(b["html"] for b in role_blocks)
+ font_size = spec.block_constraints.get("font_size_px", 15.2)
+ padding = spec.block_constraints.get("padding_px", 20)
+
+ container_htmls.append(
+ f'
\n'
+ f'{inner_html}\n
'
+ )
+
+ # 미배정 블록
+ for b in block_list:
+ if id(b) not in assigned_ids:
+ container_htmls.append(b["html"])
+
+ html = "\n".join(container_htmls)
+
+ elif len(block_list) == 1:
+ html = block_list[0]["html"]
else:
- # 여러 블록을 flex-column으로 세로 쌓기
- inner = "\n".join(data["htmls"])
+ inner = "\n".join(b["html"] for b in block_list)
html = (
f'
\n'
f'{inner}\n
'
)
+
result.append({"area": area, "html": html})
return result
@@ -205,6 +263,11 @@ def render_multi_page(layout_concept: dict[str, Any]) -> str:
block_type = block.get("type", "")
block_data = block.get("data", {})
+ # 높이 자동 조치: _strip_sub_text 플래그 처리
+ if block_data.get("_strip_sub_text"):
+ block_data.pop("sub_text", None)
+ block_data.pop("_strip_sub_text", None)
+
# P2-B: SVG 시각화 블록은 좌표 사전 계산
block_data = _preprocess_svg_data(block_type, block_data)
@@ -226,13 +289,19 @@ def render_multi_page(layout_concept: dict[str, Any]) -> str:
f'
블록 템플릿 미발견: {block_type}
'
)
+ # Phase N-3: max-height CSS 래퍼 제거.
+ # 콘텐츠는 렌더링 전에 _max_chars로 맞춘다. CSS로 사후에 자르지 않는다.
+ # overflow는 slide_measurer가 scrollHeight > clientHeight로 감지한다.
+
blocks_raw.append({
"area": block.get("area", "main"),
"html": rendered_html,
+ "_topic_id": block.get("topic_id"), # Phase O: 컨테이너 매칭용
})
- # Fix 1: 같은 area 블록 그룹핑
- blocks_grouped = _group_blocks_by_area(blocks_raw)
+ # Phase O: 비중 기반 컨테이너 그룹핑
+ page_container_specs = layout_concept.get("_container_specs")
+ blocks_grouped = _group_blocks_by_area(blocks_raw, container_specs=page_container_specs)
# A-1: area별 CSS 변수 override 주입
area_styles = page.get("area_styles", {})
diff --git a/src/slide_measurer.py b/src/slide_measurer.py
new file mode 100644
index 0000000..2c1869f
--- /dev/null
+++ b/src/slide_measurer.py
@@ -0,0 +1,281 @@
+"""Phase L: 슬라이드 렌더링 측정 에이전트.
+
+Selenium headless Chrome으로 HTML을 실제 렌더링하고
+각 zone/block의 px 높이를 정확히 측정한다.
+
+LLM 추정이 아닌 브라우저 엔진 측정. 결정론적.
+"""
+from __future__ import annotations
+
+import logging
+from typing import Any
+
+from selenium import webdriver
+from selenium.webdriver.chrome.options import Options
+from selenium.webdriver.chrome.service import Service
+
+from src.config import settings
+
+logger = logging.getLogger(__name__)
+
+# JavaScript: 각 zone과 블록의 실제 높이를 측정
+_MEASURE_SCRIPT = """
+ var slide = document.querySelector('.slide');
+ if (!slide) return {error: 'slide not found'};
+
+ var result = {
+ slide: {
+ scrollHeight: slide.scrollHeight,
+ clientHeight: slide.clientHeight,
+ overflowed: slide.scrollHeight > slide.clientHeight,
+ excess_px: Math.max(0, slide.scrollHeight - slide.clientHeight)
+ },
+ zones: {},
+ containers: {}
+ };
+
+ // Zone 측정 (area-* 클래스)
+ var areaDivs = slide.querySelectorAll('[class*="area-"]');
+ for (var i = 0; i < areaDivs.length; i++) {
+ var zone = areaDivs[i];
+ var areaMatch = zone.className.match(/area-(\\w+)/);
+ if (!areaMatch) continue;
+ var areaName = areaMatch[1];
+
+ var blocks = [];
+ var blockDivs = zone.querySelectorAll('[class*="block-"]');
+ for (var j = 0; j < blockDivs.length; j++) {
+ var block = blockDivs[j];
+ var blockMatch = block.className.match(/block-([\\w-]+)/);
+ var blockName = blockMatch ? blockMatch[1] : block.className;
+ blocks.push({
+ block_type: blockName,
+ scrollHeight: Math.round(block.scrollHeight),
+ clientHeight: Math.round(block.clientHeight),
+ offsetHeight: Math.round(block.offsetHeight),
+ overflowed: block.scrollHeight > block.clientHeight + 2,
+ excess_px: Math.max(0, Math.round(block.scrollHeight - block.clientHeight))
+ });
+ }
+
+ result.zones[areaName] = {
+ scrollHeight: Math.round(zone.scrollHeight),
+ clientHeight: Math.round(zone.clientHeight),
+ overflowed: zone.scrollHeight > zone.clientHeight + 2,
+ excess_px: Math.max(0, Math.round(zone.scrollHeight - zone.clientHeight)),
+ block_count: blocks.length,
+ blocks: blocks
+ };
+ }
+
+ // Phase O: 컨테이너 측정 (container-* 클래스)
+ var containerDivs = slide.querySelectorAll('[class*="container-"]');
+ for (var k = 0; k < containerDivs.length; k++) {
+ var container = containerDivs[k];
+ var containerMatch = container.className.match(/container-(.+)/);
+ if (!containerMatch) continue;
+ var containerName = containerMatch[1];
+
+ var cBlocks = [];
+ var cBlockDivs = container.querySelectorAll('[class*="block-"]');
+ for (var m = 0; m < cBlockDivs.length; m++) {
+ var cBlock = cBlockDivs[m];
+ var cBlockMatch = cBlock.className.match(/block-([\\w-]+)/);
+ var cBlockName = cBlockMatch ? cBlockMatch[1] : cBlock.className;
+ cBlocks.push({
+ block_type: cBlockName,
+ scrollHeight: Math.round(cBlock.scrollHeight),
+ clientHeight: Math.round(cBlock.clientHeight),
+ overflowed: cBlock.scrollHeight > cBlock.clientHeight + 2,
+ excess_px: Math.max(0, Math.round(cBlock.scrollHeight - cBlock.clientHeight))
+ });
+ }
+
+ result.containers[containerName] = {
+ scrollHeight: Math.round(container.scrollHeight),
+ clientHeight: Math.round(container.clientHeight),
+ allocatedHeight: parseInt(container.style.height) || 0,
+ overflowed: container.scrollHeight > container.clientHeight + 2,
+ excess_px: Math.max(0, Math.round(container.scrollHeight - container.clientHeight)),
+ block_count: cBlocks.length,
+ blocks: cBlocks
+ };
+ }
+
+ return result;
+"""
+
+
+def measure_rendered_heights(html: str) -> dict[str, Any]:
+ """렌더링된 HTML의 각 zone/block 실제 px 높이를 측정한다.
+
+ Selenium headless Chrome 사용. 결정론적.
+ viewport 크기는 config에서 읽음 (하드코딩 아님).
+
+ Args:
+ html: 렌더링할 완성 HTML 문자열
+
+ Returns:
+ {
+ "slide": {"scrollHeight": 750, "clientHeight": 720, "overflowed": true, ...},
+ "zones": {
+ "body": {"scrollHeight": 520, "clientHeight": 490, "overflowed": true, "blocks": [...]},
+ "sidebar": {"scrollHeight": 400, "clientHeight": 490, "overflowed": false, ...},
+ ...
+ }
+ }
+ """
+ options = Options()
+ options.add_argument("--headless=new")
+ options.add_argument("--disable-gpu")
+ options.add_argument("--no-sandbox")
+ options.add_argument("--disable-dev-shm-usage")
+ options.add_argument(
+ f"--window-size={settings.slide_width},{settings.slide_height + 200}"
+ )
+
+ driver = None
+ try:
+ driver = webdriver.Chrome(options=options)
+
+ # HTML을 data URI로 로드
+ import urllib.parse
+ encoded = urllib.parse.quote(html)
+ driver.get(f"data:text/html;charset=utf-8,{encoded}")
+
+ # 폰트 로딩 대기 (Pretendard CDN)
+ try:
+ driver.execute_script("return document.fonts.ready")
+ except Exception:
+ pass # 폰트 API 미지원 시 무시
+
+ # 측정 실행
+ result = driver.execute_script(_MEASURE_SCRIPT)
+
+ if result and "error" not in result:
+ _log_measurement(result)
+ return result
+
+ logger.warning(f"[측정] 실패: {result}")
+ return {"slide": {}, "zones": {}}
+
+ except Exception as e:
+ logger.warning(f"[측정] Selenium 오류: {e}")
+ return {"slide": {}, "zones": {}}
+
+ finally:
+ if driver:
+ try:
+ driver.quit()
+ except Exception:
+ pass
+
+
+def format_measurement_for_kei(
+ measurement: dict[str, Any],
+ allocation: dict[int, int] | None = None,
+) -> str:
+ """측정 결과를 Kei 검수에 전달할 텍스트로 포맷한다.
+
+ Args:
+ measurement: measure_rendered_heights() 결과
+ allocation: allocate_height_budget() 결과 (있으면 할당 대비 비교)
+
+ Returns:
+ Kei에게 전달할 측정 결과 텍스트
+ """
+ lines = ["## 실제 렌더링 측정 결과 (Selenium)"]
+
+ slide = measurement.get("slide", {})
+ if slide:
+ status = "OK" if not slide.get("overflowed") else f"+{slide.get('excess_px', 0)}px 초과"
+ lines.append(
+ f"- 슬라이드 전체: {slide.get('scrollHeight', '?')}px / "
+ f"{slide.get('clientHeight', '?')}px — {status}"
+ )
+
+ for zone_name, zone_data in measurement.get("zones", {}).items():
+ status = "OK" if not zone_data.get("overflowed") else f"+{zone_data.get('excess_px', 0)}px 초과"
+ lines.append(
+ f"- {zone_name} zone: 실제 {zone_data.get('scrollHeight', '?')}px / "
+ f"가용 {zone_data.get('clientHeight', '?')}px — {status}"
+ )
+
+ for block in zone_data.get("blocks", []):
+ block_status = "OK" if not block.get("overflowed") else f"+{block.get('excess_px', 0)}px 잘림"
+ height = block.get("scrollHeight", "?")
+
+ # zone 내 비중 계산
+ zone_height = zone_data.get("clientHeight", 1)
+ ratio_pct = round(height / zone_height * 100) if isinstance(height, (int, float)) and zone_height > 0 else "?"
+
+ lines.append(
+ f" - {block.get('block_type', '?')}: "
+ f"{height}px ({ratio_pct}%) — {block_status}"
+ )
+
+ return "\n".join(lines)
+
+
+def capture_slide_screenshot(html: str) -> str | None:
+ """Phase N-4: 렌더링된 슬라이드의 스크린샷을 base64 PNG로 캡처한다.
+
+ Selenium 4.x WebElement.screenshot_as_base64 사용.
+ 반환: 순수 base64 문자열 (data URI prefix 없음). 실패 시 None.
+ """
+ options = Options()
+ options.add_argument("--headless=new")
+ options.add_argument("--disable-gpu")
+ options.add_argument("--no-sandbox")
+ options.add_argument("--disable-dev-shm-usage")
+ options.add_argument("--force-device-scale-factor=1")
+ options.add_argument(
+ f"--window-size={settings.slide_width},{settings.slide_height + 200}"
+ )
+
+ driver = None
+ try:
+ driver = webdriver.Chrome(options=options)
+
+ import urllib.parse
+ encoded = urllib.parse.quote(html)
+ driver.get(f"data:text/html;charset=utf-8,{encoded}")
+
+ # 폰트 로딩 대기
+ try:
+ driver.execute_script("return document.fonts.ready")
+ except Exception:
+ pass
+
+ from selenium.webdriver.common.by import By
+ slide = driver.find_element(By.CSS_SELECTOR, ".slide")
+ screenshot_b64 = slide.screenshot_as_base64
+
+ logger.info(f"[스크린샷] 캡처 완료: {len(screenshot_b64)}자 base64")
+ return screenshot_b64
+
+ except Exception as e:
+ logger.warning(f"[스크린샷] Selenium 캡처 실패: {e}")
+ return None
+
+ finally:
+ if driver:
+ try:
+ driver.quit()
+ except Exception:
+ pass
+
+
+def _log_measurement(result: dict[str, Any]) -> None:
+ """측정 결과를 로그에 출력한다."""
+ slide = result.get("slide", {})
+ overflow_status = "OK" if not slide.get("overflowed") else f"초과 +{slide.get('excess_px')}px"
+ logger.info(f"[측정] 슬라이드: {slide.get('scrollHeight')}px / {slide.get('clientHeight')}px — {overflow_status}")
+
+ for zone_name, zone_data in result.get("zones", {}).items():
+ zone_status = "OK" if not zone_data.get("overflowed") else f"초과 +{zone_data.get('excess_px')}px"
+ logger.info(
+ f"[측정] {zone_name}: {zone_data.get('scrollHeight')}px / "
+ f"{zone_data.get('clientHeight')}px — {zone_status} "
+ f"({zone_data.get('block_count', 0)}개 블록)"
+ )
diff --git a/src/space_allocator.py b/src/space_allocator.py
new file mode 100644
index 0000000..2d15ab0
--- /dev/null
+++ b/src/space_allocator.py
@@ -0,0 +1,312 @@
+"""Phase O: 컨테이너 기반 공간 할당 시스템.
+
+Kei 비중 → 컨테이너 px 확정 → 블록 제약 계산 → 편집자 스펙 생성.
+LLM 추정이 아닌 결정론적 계산.
+
+주요 함수:
+- calculate_container_specs(): Kei 비중 → 역할별 ContainerSpec
+- finalize_block_specs(): 컨테이너 크기 → 블록별 내부 스펙
+- calculate_trim_chars(): 초과 px → 삭제 글자 수
+"""
+from __future__ import annotations
+
+import math
+import logging
+from dataclasses import dataclass, field
+from typing import Any
+
+logger = logging.getLogger(__name__)
+
+
+# ──────────────────────────────────────
+# height_cost → px 범위 매핑
+# ──────────────────────────────────────
+HEIGHT_COST_PX_RANGE = {
+ "compact": (30, 80),
+ "medium": (80, 200),
+ "large": (200, 350),
+ "xlarge": (350, 500),
+}
+
+HEIGHT_COST_ORDER = {"compact": 0, "medium": 1, "large": 2, "xlarge": 3}
+
+# 역할별 zone 매핑 (기본)
+ROLE_ZONE_MAP = {
+ "본심": "body",
+ "배경": "body",
+ "첨부": "sidebar",
+ "결론": "footer",
+}
+
+# 폰트 설정 기본값
+DEFAULT_FONT_SIZE_PX = 15.2
+DEFAULT_LINE_HEIGHT = 1.7
+DEFAULT_AVG_CHAR_WIDTH_PX = 14.4 # fonttools 실측 기반 (Pretendard 한글)
+
+
+# ──────────────────────────────────────
+# ContainerSpec 데이터 클래스
+# ──────────────────────────────────────
+@dataclass
+class ContainerSpec:
+ """역할별 컨테이너 스펙."""
+ role: str # "본심", "배경", "첨부", "결론"
+ zone: str # "body", "sidebar", "footer"
+ topic_ids: list[int] # 이 컨테이너에 속하는 topic ID들
+ weight: float # Kei가 판단한 비중 (0.0~1.0)
+ height_px: int # 컨테이너 높이 (px)
+ width_px: int # 컨테이너 너비 (px)
+ max_height_cost: str # 허용 최대 height_cost ("compact"/"medium"/"large"/"xlarge")
+ block_constraints: dict = field(default_factory=dict) # 블록 내부 제약
+
+
+# ──────────────────────────────────────
+# O-1: 컨테이너 스펙 계산
+# ──────────────────────────────────────
+def calculate_container_specs(
+ page_structure: dict[str, Any],
+ topics: list[dict[str, Any]],
+ preset: dict[str, Any],
+ slide_width: int = 1280,
+ slide_height: int = 720,
+ gap_px: int = 20,
+) -> dict[str, ContainerSpec]:
+ """Kei 비중 → 역할별 ContainerSpec 계산.
+
+ 결정론적. AI 호출 없음.
+
+ Args:
+ page_structure: Kei 판단 {"본심": {"topic_ids": [3], "weight": 0.6}, ...}
+ topics: 각 topic의 purpose, role, layer
+ preset: 프리셋 zone 정보 (budget_px, width_pct)
+ slide_width: 슬라이드 너비 (px)
+ slide_height: 슬라이드 높이 (px)
+ gap_px: 컨테이너 간 간격 (px)
+
+ Returns:
+ {"본심": ContainerSpec(...), "배경": ContainerSpec(...), ...}
+ """
+ zones = preset.get("zones", {})
+ specs: dict[str, ContainerSpec] = {}
+
+ # zone별로 해당 역할들의 비중 합산
+ zone_roles: dict[str, list[tuple[str, dict]]] = {} # zone → [(role, info), ...]
+ for role_name, role_info in page_structure.items():
+ if not isinstance(role_info, dict):
+ continue
+ zone = ROLE_ZONE_MAP.get(role_name, "body")
+ if zone not in zone_roles:
+ zone_roles[zone] = []
+ zone_roles[zone].append((role_name, role_info))
+
+ for zone_name, role_list in zone_roles.items():
+ zone_info = zones.get(zone_name, {})
+ zone_budget = zone_info.get("budget_px", 490)
+ zone_width_pct = zone_info.get("width_pct", 100)
+ zone_width_px = int(slide_width * zone_width_pct / 100 * 0.85) # 패딩 제외
+
+ # 이 zone 안의 역할별 비중 비율 계산
+ total_weight = sum(info.get("weight", 0.25) for _, info in role_list)
+ if total_weight <= 0:
+ total_weight = 1.0
+
+ # 간격 제외
+ total_gap = gap_px * max(0, len(role_list) - 1)
+ available = zone_budget - total_gap
+
+ for role_name, role_info in role_list:
+ weight = role_info.get("weight", 0.25)
+ topic_ids = role_info.get("topic_ids", [])
+
+ # 비중 비율로 높이 할당
+ ratio = weight / total_weight
+ height_px = max(50, int(available * ratio))
+
+ # 블록 내부 제약 계산 — topic당 높이로 판단
+ topic_count = max(1, len(topic_ids))
+ per_topic_px = height_px // topic_count
+
+ # height_cost 허용 범위: topic당 높이 기준 (컨테이너 전체가 아님)
+ max_cost = _max_allowed_height_cost(per_topic_px)
+ font_size, padding, line_h = _determine_typography(height_px // topic_count)
+ constraints = _calculate_block_constraints(
+ height_px, zone_width_px, topic_count, font_size, line_h, padding
+ )
+ constraints["font_size_px"] = font_size
+ constraints["padding_px"] = padding
+ constraints["line_height"] = line_h
+
+ specs[role_name] = ContainerSpec(
+ role=role_name,
+ zone=zone_name,
+ topic_ids=topic_ids,
+ weight=weight,
+ height_px=height_px,
+ width_px=zone_width_px,
+ max_height_cost=max_cost,
+ block_constraints=constraints,
+ )
+
+ logger.info(
+ f"[O-1] 컨테이너 스펙: "
+ + ", ".join(f"{r}={s.height_px}px({s.max_height_cost})" for r, s in specs.items())
+ )
+ return specs
+
+
+def _max_allowed_height_cost(container_height_px: int) -> str:
+ """컨테이너 높이에서 허용되는 최대 height_cost."""
+ if container_height_px >= 350:
+ return "xlarge"
+ elif container_height_px >= 200:
+ return "large"
+ elif container_height_px >= 80:
+ return "medium"
+ else:
+ return "compact"
+
+
+def _determine_typography(per_block_height_px: int) -> tuple[float, int, float]:
+ """컨테이너 높이에 따른 폰트/패딩/줄간격 결정."""
+ if per_block_height_px >= 300:
+ return (15.2, 20, 1.7)
+ elif per_block_height_px >= 150:
+ return (14.0, 14, 1.6)
+ elif per_block_height_px >= 80:
+ return (13.0, 10, 1.5)
+ else:
+ return (12.0, 8, 1.4)
+
+
+def _calculate_block_constraints(
+ height_px: int,
+ width_px: int,
+ topic_count: int,
+ font_size_px: float,
+ line_height: float,
+ padding_px: int,
+) -> dict:
+ """컨테이너 크기에서 블록 내부 제약을 수학적으로 계산."""
+ per_topic_height = max(30, (height_px - padding_px * 2) // topic_count)
+ line_height_px = font_size_px * line_height
+ max_lines = max(1, int(per_topic_height / line_height_px))
+ chars_per_line = max(5, int((width_px - padding_px * 2) / (font_size_px * 0.95)))
+ max_items = max(1, max_lines // 2)
+ max_chars_total = max_lines * chars_per_line
+
+ return {
+ "max_lines": max_lines,
+ "max_items": max_items,
+ "chars_per_line": chars_per_line,
+ "max_chars_total": max(20, max_chars_total),
+ "max_chars_per_item": max(20, max_chars_total // max(1, max_items)),
+ }
+
+
+# ──────────────────────────────────────
+# O-1 유틸: topic_id → ContainerSpec 매핑
+# ──────────────────────────────────────
+def find_container_for_topic(
+ topic_id: int | None,
+ container_specs: dict[str, ContainerSpec],
+) -> ContainerSpec | None:
+ """topic_id로 해당 ContainerSpec을 찾는다."""
+ if topic_id is None:
+ return None
+ for spec in container_specs.values():
+ if topic_id in spec.topic_ids:
+ return spec
+ return None
+
+
+# ──────────────────────────────────────
+# O-3: 블록 스펙 확정
+# ──────────────────────────────────────
+def finalize_block_specs(
+ blocks: list[dict[str, Any]],
+ container_specs: dict[str, ContainerSpec],
+ catalog_map: dict[str, dict] | None = None,
+) -> list[dict[str, Any]]:
+ """각 블록의 내부 스펙을 컨테이너 크기에 맞게 확정한다.
+
+ 결정론적. AI 호출 없음.
+
+ 확정 필드:
+ - _container_height_px, _container_width_px
+ - _max_items, _max_chars_per_item, _max_chars_total
+ - _font_size_px, _padding_px, _line_height
+ """
+ for block in blocks:
+ tid = block.get("topic_id")
+ spec = find_container_for_topic(tid, container_specs)
+ if not spec:
+ continue
+
+ # 같은 컨테이너 안의 블록 수 (높이 분배)
+ siblings = [b for b in blocks
+ if find_container_for_topic(b.get("topic_id"), container_specs) == spec
+ and b.get("topic_id") is not None]
+ sibling_count = max(1, len(siblings))
+ per_block_height = max(40, spec.height_px // sibling_count)
+
+ # 폰트/패딩 결정
+ font_size, padding, line_h = _determine_typography(per_block_height)
+
+ # 블록별 제약 계산
+ constraints = _calculate_block_constraints(
+ per_block_height, spec.width_px, 1, font_size, line_h, padding
+ )
+
+ # 블록 타입별 세부 조정
+ block_type = block.get("type", "")
+ if block_type in ("dark-bullet-list",):
+ block["_max_items"] = min(constraints["max_items"], 5)
+ block["_max_chars_per_item"] = constraints["max_chars_per_item"]
+ elif block_type in ("card-numbered", "card-icon-desc"):
+ block["_max_items"] = constraints["max_items"]
+ block["_max_chars_per_item"] = constraints["max_chars_per_item"]
+ elif block_type in ("compare-2col-split", "compare-3col-badge", "table-simple-striped"):
+ block["_max_items"] = constraints["max_items"] # 행 수
+ block["_max_chars_per_item"] = constraints["max_chars_per_item"]
+ elif block_type in ("comparison-2col",):
+ block["_max_chars_per_item"] = constraints["max_chars_total"] // 2
+ elif block_type in ("banner-gradient",):
+ block["_max_chars_total"] = constraints["chars_per_line"]
+ else:
+ pass # 기본값 사용
+
+ # 공통 필드
+ block["_container_height_px"] = per_block_height
+ block["_container_width_px"] = spec.width_px
+ block["_max_chars_total"] = constraints["max_chars_total"]
+ block["_font_size_px"] = font_size
+ block["_padding_px"] = padding
+ block["_line_height"] = line_h
+
+ logger.info(
+ f"[O-3] 블록 스펙 확정: "
+ + ", ".join(
+ f"t{b.get('topic_id')}={b.get('_container_height_px','?')}px"
+ for b in blocks if b.get("topic_id") is not None
+ )
+ )
+ return blocks
+
+
+# ──────────────────────────────────────
+# 기존 유틸 (Phase L 호환)
+# ──────────────────────────────────────
+def calculate_trim_chars(
+ excess_px: int,
+ container_width_px: int,
+ font_size_px: float = DEFAULT_FONT_SIZE_PX,
+ line_height: float = DEFAULT_LINE_HEIGHT,
+ avg_char_width_px: float = DEFAULT_AVG_CHAR_WIDTH_PX,
+) -> int:
+ """초과 px에서 삭제할 글자 수를 계산한다."""
+ if excess_px <= 0:
+ return 0
+ line_height_px = font_size_px * line_height
+ lines_to_remove = math.ceil(excess_px / line_height_px)
+ chars_per_line = int(container_width_px / avg_char_width_px)
+ return max(lines_to_remove * chars_per_line, 10)
diff --git a/templates/blocks/cards/card-icon-desc.html b/templates/blocks/cards/card-icon-desc.html
index 9cd4db1..b2d31e1 100644
--- a/templates/blocks/cards/card-icon-desc.html
+++ b/templates/blocks/cards/card-icon-desc.html
@@ -6,7 +6,7 @@
슬롯: cards[] (각 카드에 icon, title, description)
Figma 원본: 2-3_01 아이콘 3열 설명
-->
-
+
{% for card in cards %}
{% if card.icon %}
{{ card.icon }}
{% endif %}
diff --git a/templates/blocks/cards/card-tag-image.html b/templates/blocks/cards/card-tag-image.html
index 643f194..eb9751a 100644
--- a/templates/blocks/cards/card-tag-image.html
+++ b/templates/blocks/cards/card-tag-image.html
@@ -6,7 +6,7 @@
슬롯: cards[] (각 카드에 tag, tag_color, image, title, description)
Figma 원본: 2-3_01 "산업별 특성과 현장의 모습" (제조, 건축, 인프라/토목)
-->
-
+
{% for card in cards %}
{{ card.tag }}
diff --git a/templates/catalog.yaml b/templates/catalog.yaml
index af2fc3d..4ce5f26 100644
--- a/templates/catalog.yaml
+++ b/templates/catalog.yaml
@@ -1,634 +1,556 @@
version: '2.0'
blocks:
+# ═══════════════════════════════════════
+# HEADERS (5개) — 꼭지/섹션 제목용
+# ═══════════════════════════════════════
- id: section-title-with-bg
name: 배경 이미지 타이틀
template: blocks/headers/section-title-with-bg.html
height_cost: large
visual: 전체 너비 배경 이미지(파란 그라데이션+웨이브) 위에 흰색 영문 소제목(15px) + 한글 대제목(35px). 높이 약 500px.
- when: '자세히보기 페이지의 맨 첫 화면. 배경 이미지가 있고 그 위에 타이틀을 올릴 때. 페이지의 주제를 시각적으로 강렬하게 선언할 때.
-
- '
- not_for: '슬라이드 내부의 소제목 → topic-left-right 또는 topic-center 사용. 배경 이미지 없이 텍스트만 → topic-center
- 사용. 높이 예산이 200px 이하일 때 → section-header-bar 사용.
-
- '
+ when: '자세히보기(detail) 페이지의 맨 첫 화면 전용. 배경 이미지 위에 타이틀을 올려 페이지 주제를 시각적으로 강렬하게 선언할 때.'
+ not_for: '일반 슬라이드 내부 소제목 → topic-left-right 또는 topic-center 사용. 배경 이미지 없이 텍스트만 → topic-center. 높이 200px 이하 → section-header-bar.'
+ purpose_fit: []
slots:
- required:
- - title_ko
- optional:
- - title_en
- - breadcrumb
- - bg_image
+ required: [title_ko]
+ optional: [title_en, breadcrumb, bg_image]
+
- id: section-header-bar
name: 섹션 헤더 바
template: blocks/headers/section-header-bar.html
height_cost: compact
- visual: 전체 너비 파란 배경 바(높이 ~50px) + 중앙 흰색 제목. 섹션 구분용. 컴팩트.
- when: '섹션 시작을 가볍게 표시할 때. 같은 페이지 안에서 주제가 전환될 때. 높이 예산이 적을 때 섹션 구분이 필요할 때.
-
- '
- not_for: '페이지 전체 타이틀 → section-title-with-bg 사용. 꼭지별 소제목 → topic-left-right 또는 topic-numbered
- 사용.
-
- '
+ visual: 전체 너비 파란 배경 바(~50px) + 중앙 흰색 제목. 섹션 구분용. 컴팩트.
+ when: '같은 페이지 안에서 주제 전환이 필요할 때. 높이 예산이 적을 때 섹션 구분.'
+ not_for: '페이지 전체 타이틀 → section-title-with-bg. 꼭지별 소제목 → topic-left-right 또는 topic-numbered.'
+ purpose_fit: []
slots:
- required:
- - title
- optional:
- - subtitle
+ required: [title]
+ optional: [subtitle]
+ schema:
+ title: {max_lines: 1, font_size: 18, ref_chars: {body: 25, sidebar: 20}, note: '18px bold white, 중앙정렬'}
+ subtitle: {max_lines: 1, font_size: 13, ref_chars: {body: 40, sidebar: 30}, note: '13px, 1줄'}
+
- id: topic-left-right
name: 좌우 꼭지 헤더
template: blocks/headers/topic-left-right.html
height_cost: compact
- visual: 좌측에 파란 굵은 제목(24px, 240px 너비) + 우측에 본문 설명. 가로 2단 배치.
- when: '꼭지 시작부에 질문형 제목 + 답변형 설명 구조일 때. 예: ''단순 BIM의 적용이 D/X가 아닙니다'' + ''설명...'' 좌측에
- 핵심 주장, 우측에 근거/설명을 배치할 때.
-
- '
- not_for: '중앙 정렬 대제목 → topic-center 사용. 번호가 붙은 순서형 꼭지 → topic-numbered 사용. 섹션 전체
- 타이틀 → section-title-with-bg 사용.
-
- '
+ visual: 좌측에 파란 굵은 제목(24px, 240px 고정) + 우측에 본문 설명(16px). 가로 2단.
+ when: '좌측에 핵심 주장/질문, 우측에 근거/설명을 배치하는 구조. 문제 제기의 도입부로 적합. 예: "용어의 혼용" + "DX와 BIM이 혼용되고 있다..."'
+ not_for: '중앙 정렬 대제목 → topic-center. 번호가 붙은 순서형 → topic-numbered. 섹션 전체 타이틀 → section-title-with-bg.'
+ purpose_fit: [문제제기]
slots:
- required:
- - title
- - description
+ required: [title, description]
optional: []
+ schema:
+ title: {max_lines: 2, font_size: 24, ref_chars: {body: 20}, note: '24px bold, 240px 고정폭'}
+ description: {max_lines: 2, font_size: 16, ref_chars: {body: 100}, note: '16px, 510px 너비'}
+
- id: topic-center
name: 중앙 정렬 꼭지 헤더
template: blocks/headers/topic-center.html
height_cost: medium
visual: 중앙 정렬 대제목(26px 굵게) + 파란 서브타이틀 + 하단 설명. 단독 강조.
- when: '하나의 주제를 페이지 중심에 크게 선언할 때. 예: ''디지털전환을 위한 S/W 필요성'' 서브타이틀이나 부연 설명이 함께 올 때.
-
- '
- not_for: '좌:제목 우:설명 구조 → topic-left-right 사용. 번호 순서 → topic-numbered 사용.
-
- '
+ when: '하나의 주제를 페이지 중심에 크게 선언할 때. sidebar 영역의 섹션 라벨로도 사용 가능.'
+ not_for: '좌:제목 우:설명 구조 → topic-left-right. 번호 순서 → topic-numbered.'
+ purpose_fit: []
slots:
- required:
- - title
- optional:
- - subtitle
- - description
+ required: [title]
+ optional: [subtitle, description]
+ schema:
+ title: {max_lines: 1, font_size: 26, ref_chars: {body: 25, sidebar: 20}, note: '26px bold'}
+ subtitle: {max_lines: 1, font_size: 14, ref_chars: {body: 40, sidebar: 30}, note: '14px accent'}
+ description: {max_lines: 3, font_size: 16, ref_chars: {body: 120, sidebar: 80}, note: '16px'}
+
- id: topic-numbered
name: 번호 꼭지 헤더
template: blocks/headers/topic-numbered.html
height_cost: compact
visual: 파란 원형 번호(①②③) + 굵은 제목 + 파란 구분선 + 설명. 세로 배치.
- when: '순서가 있는 꼭지를 시작할 때. 1번, 2번, 3번 식의 단계별 섹션. 실행 조건, 요구사항 등을 순서대로 설명할 때.
-
- '
- not_for: '순서 없는 꼭지 → topic-left-right 또는 topic-center 사용. 카드 안의 순서 → card-numbered
- 사용.
-
- '
+ when: '순서가 있는 꼭지를 시작할 때. 1번, 2번, 3번 식의 단계별 섹션.'
+ not_for: '순서 없는 꼭지 → topic-left-right 또는 topic-center. 카드 안의 순서 → card-numbered.'
+ purpose_fit: []
slots:
- required:
- - number
- - title
- optional:
- - description
- - color
+ required: [number, title]
+ optional: [description, color]
+
+# ═══════════════════════════════════════
+# CARDS (10개) — 항목 나열/비교용
+# ═══════════════════════════════════════
- id: card-image-3col
name: 이미지 카드 3열
template: blocks/cards/card-image-3col.html
height_cost: large
- visual: 3열 카드. 각 카드 상단에 이미지(160px) + 하단에 색상 밑줄 제목 + 영문 + 불릿 목록.
- when: '3개 항목을 각각 고유 이미지와 함께 설명할 때. 예: 설계단계(3D 모델) / 시공단계(현장) / 유지관리(자산) 단계별 설명에
- 이미지가 핵심인 경우.
-
- '
- not_for: '이미지 없이 텍스트만 → card-icon-desc 사용. 키워드+짧은 설명만 강조 → card-dark-overlay 사용.
- 2개 비교 → compare-pill-pair + comparison-table 조합 사용.
-
- '
+ visual: 3열 카드. 각 카드 상단에 이미지(160px) + 하단에 색상 밑줄 제목 + 불릿 목록.
+ when: '이미지가 핵심인 항목 3개를 나란히. 예: 설계단계(3D모델) / 시공단계(현장) / 유지관리(자산).'
+ not_for: '이미지 없이 텍스트만 → card-icon-desc. 키워드+짧은 설명만 → card-dark-overlay. 2개 비교 → compare-pill-pair.'
+ purpose_fit: [핵심전달, 근거사례]
slots:
- required:
- - cards[]
+ required: ['cards[]']
optional: []
+
- id: card-dark-overlay
name: 다크 오버레이 카드
template: blocks/cards/card-dark-overlay.html
height_cost: medium
visual: 3~5열 카드. 다크 배경 이미지 + 그라데이션 오버레이 + 흰색 굵은 제목 + 짧은 설명.
- when: '키워드+짧은 설명(2줄 이내)을 시각적으로 강조할 때. 예: 협업지원/오류감소/생산성향상/비용절감/데이터관리 배경 이미지가 있는 키워드
- 나열.
-
- '
- not_for: '긴 설명(3줄 이상) → card-icon-desc 사용. 이미지가 핵심(크게 보여야 함) → card-image-3col 사용.
-
- '
+ when: '키워드를 시각적으로 강조할 때. 짧은 설명(2줄 이내)과 함께. 예: 협업지원 / 오류감소 / 생산성향상.'
+ not_for: '긴 설명(3줄+) → card-icon-desc. 이미지가 크게 보여야 함 → card-image-3col. 순서/단계 → process-horizontal.'
+ purpose_fit: [핵심전달, 구조시각화]
+ zone: full-width-only
slots:
- required:
- - cards[]
+ required: ['cards[]']
optional: []
+ schema:
+ card_title: {max_lines: 1, font_size: 18, ref_chars: {body: 15}, note: '18px bold white, 1줄'}
+ card_description: {max_lines: 2, font_size: 12, ref_chars: {body: 30}, note: '12px white, 1~2줄'}
+ max_cards: {body: 5, note: '카드 수'}
+
- id: card-tag-image
name: 태그 이미지 카드
template: blocks/cards/card-tag-image.html
height_cost: large
visual: 3열 카드. 좌상단 색상 태그 라벨 + 이미지 + 제목 + 설명.
- when: '카테고리별 분류가 핵심일 때. 태그로 구분. 예: 제조업(파란) / 건축(초록) / 인프라·토목(빨간)
-
- '
- not_for: '태그 불필요 → card-image-3col 사용. 이미지 없음 → card-icon-desc 사용.
-
- '
+ when: '카테고리 태그로 분류가 핵심일 때. 예: 제조업(파란) / 건축(초록) / 인프라(빨간).'
+ not_for: '태그 불필요 → card-image-3col. 이미지 없음 → card-icon-desc.'
+ purpose_fit: [핵심전달]
slots:
- required:
- - cards[]
+ required: ['cards[]']
optional: []
+
- id: card-icon-desc
name: 아이콘 설명 카드
template: blocks/cards/card-icon-desc.html
height_cost: medium
visual: 2~4열. 중앙 큰 이모지 아이콘(2.5rem) + 굵은 제목 + 설명. 밝은 배경.
- when: '기능/특성/장점을 아이콘과 함께 나열. 예: 🔧기술기반 / 💻S/W역량 / 🌏여건조성
-
- '
- not_for: '이미지(사진) 필요 → card-image-3col 사용. 순서 번호 → card-numbered 사용.
-
- '
+ when: '독립적인 항목/개념/특성을 이모지 아이콘과 함께 나열. 순서 없는 개별 항목. 예: 🔧기술기반 / 💻S/W역량 / 🌏여건조성. 독립 사례를 각각 아이콘으로 구분하여 나열할 때도 적합.'
+ not_for: '이미지(사진) 필요 → card-image-3col. 순서 번호 필요 → card-numbered. 텍스트만(아이콘 불필요) → dark-bullet-list.'
+ purpose_fit: [핵심전달, 근거사례, 구조시각화]
+ zone: full-width-only
slots:
- required:
- - cards[]
+ required: ['cards[]']
optional: []
+ schema:
+ card_title: {max_lines: 1, font_size: 15, ref_chars: {body: 10}, note: '15px bold, 1줄'}
+ card_description: {max_lines: 3, font_size: 13, ref_chars: {body: 60}, note: '13px, 3줄 이내'}
+ max_cards: {body: 4, note: '카드 수 (3열 grid)'}
+
- id: card-compare-3col
name: 3단 비교 카드
template: blocks/cards/card-compare-3col.html
height_cost: large
- visual: 3열 카드. 각 카드 상단 색상 헤더(제목+서브) + 이미지 + 불릿.
- when: '3개 카테고리를 비교. 각 카테고리에 다른 색상 헤더. 예: 상용SW(회색) vs 3rd Party(파랑) vs 전문SW(빨강)
-
- '
- not_for: '2개 비교 → compare-pill-pair + comparison-table 사용. 다항목 표 → compare-3col-badge
- 사용.
-
- '
+ visual: 3열 카드. 각 카드 상단 색상 헤더(제목+서브) + 이미지 + 불릿 목록.
+ when: '3개 카테고리를 비교할 때. 각 카테고리에 다른 색상 헤더. 예: 상용SW(회색) vs 3rd Party(파랑) vs 전문SW(빨강).'
+ not_for: '2개 비교 → compare-pill-pair + compare-2col-split. 다항목 표 → compare-3col-badge.'
+ purpose_fit: [핵심전달]
+ zone: full-width-only
slots:
- required:
- - cards[]
+ required: ['cards[]']
optional: []
+ schema:
+ card_title: {max_lines: 1, font_size: 15, ref_chars: {body: 15}, note: '15px bold white, 1줄'}
+ bullet_item: {max_lines: 1, font_size: 13, ref_chars: {body: 40}, note: '13px, 불릿 1개당'}
+ max_bullets_per_card: {body: 5, note: '카드당 불릿 수'}
+
- id: card-step-vertical
name: 세로 단계 카드
template: blocks/cards/card-step-vertical.html
height_cost: xlarge
visual: 세로 나열. 좌측 색상 마커(단계명) + 우측 콘텐츠 박스(제목+이미지+설명). 연결선.
- when: '생애주기 단계별 설명. 각 단계에 이미지+상세 설명. 예: 설계단계 → 시공단계 → 운영단계
-
- '
- not_for: '가로 흐름(간단) → process-horizontal 사용. 높이 예산 부족 → card-numbered 사용.
-
- '
+ when: '생애주기/프로세스 단계별 설명. 각 단계에 이미지+상세 설명. 예: 설계→시공→운영 단계.'
+ not_for: '가로 흐름(간단) → process-horizontal. 높이 예산 부족 → card-numbered. 독립 사례(순서 아님) → card-icon-desc.'
+ purpose_fit: [핵심전달, 구조시각화]
slots:
- required:
- - steps[]
+ required: ['steps[]']
optional: []
+ schema:
+ step_title: {max_lines: 1, font_size: 16, ref_chars: {body: 15, sidebar: 12}, note: '16px bold'}
+ step_description: {max_lines: 3, font_size: 14, ref_chars: {body: 60, sidebar: 40}, note: '14px, 2~3줄'}
+ max_steps: {body: 4, sidebar: 3, note: '단계 수'}
+
- id: card-image-round
name: 원형 이미지 카드
template: blocks/cards/card-image-round.html
height_cost: large
visual: 2~3열. 원형 이미지(140px, 테두리+그림자) + 제목 + 설명. 중앙 정렬.
- when: '포트폴리오형 나열. 비전/가치 표현. 원형 이미지.
-
- '
- not_for: '사각형 이미지 → card-image-3col 사용. 이미지 없음 → card-icon-desc 사용.
-
- '
+ when: '포트폴리오형 나열. 비전/가치 표현. 원형 이미지가 있는 경우.'
+ not_for: '사각형 이미지 → card-image-3col. 이미지 없음 → card-icon-desc.'
+ purpose_fit: []
slots:
- required:
- - cards[]
+ required: ['cards[]']
optional: []
+
- id: card-stat-number
name: 통계 숫자 카드
template: blocks/cards/card-stat-number.html
height_cost: medium
visual: 2~4열. 매우 큰 숫자(36px, 색상) + 단위 + 라벨 + 설명.
- when: 'KPI, 성과 수치, 목표 달성률, 비용 절감율. 예: 30% 절감 / 40% 감소 / 220명+ 인력
-
- '
- not_for: '숫자가 아닌 텍스트 → card-icon-desc 사용.
-
- '
+ when: 'KPI, 성과 수치, 달성률, 비용 절감율 등 숫자가 핵심인 데이터. 예: 30% 절감 / 220명+.'
+ not_for: '숫자가 아닌 텍스트 항목 → card-icon-desc. 비교 구조 → compare-3col-badge.'
+ purpose_fit: [핵심전달, 근거사례]
slots:
- required:
- - stats[]
+ required: ['stats[]']
optional: []
+
- id: card-numbered
name: 번호 항목 카드
template: blocks/cards/card-numbered.html
height_cost: medium
- visual: 세로 나열. 색상 원형 번호(①②③④) + 제목 + 설명. 밝은 배경.
- when: '순서가 있는 항목을 세로로 나열 (실행 단계, 조건, 요구사항). 예: 1.요구사항분석 → 2.SW개발 → 3.System통합 →
- 4.교육
-
- '
- not_for: '순서 없음 → card-icon-desc 사용. 이미지 포함 단계 → card-step-vertical 사용. 가로 흐름 →
- process-horizontal 사용.
-
- '
+ visual: 세로 나열. 색상 원형 번호(①②③) + 제목 + 설명. 밝은 배경 카드.
+ when: '번호가 의미 있는 항목 나열. 순서가 있는 단계(1→2→3)이거나, 번호로 구분되는 정의 목록. sidebar 용어 정의에 적합(1.건설산업 2.BIM 3.DX). 조건/요구사항 나열.'
+ not_for: '순서 없는 독립 항목 → card-icon-desc. 이미지 포함 단계 → card-step-vertical. 가로 흐름 → process-horizontal.'
+ purpose_fit: [용어정의, 핵심전달]
slots:
- required:
- - items[]
+ required: ['items[]']
optional: []
+
+# ═══════════════════════════════════════
+# TABLES (3개) — 비교표/데이터 표
+# ═══════════════════════════════════════
- id: compare-3col-badge
name: VS 배지 비교표
template: blocks/tables/compare-3col-badge.html
height_cost: large
visual: 3단 테이블. 좌(하늘색 헤더) | 중앙(파란 VS 배지) | 우(파란 헤더). 행별 비교.
- when: '두 개념 다항목 비교 (5행 이상). 중앙에 VS 배지. 예: BIM vs DX — S/W, 프로세스, 성과물, 활용 비교
-
- '
- not_for: '시각적 대비(짧음) → compare-pill-pair 사용. 2단 분할 → compare-2col-split 사용. 범용 데이터
- → table-simple-striped 사용.
-
- '
+ when: '두 개념의 다항목 비교(5행 이상). 구분 기준(중앙)을 두고 좌우로 비교. 예: BIM vs DX — S/W, 프로세스, 성과물 비교.'
+ not_for: '시각적 대비(짧음) → compare-pill-pair. 2단 분할 → compare-2col-split. 범용 데이터 → table-simple-striped. A vs B 간단 비교(2~3행) → comparison-2col.'
+ purpose_fit: [핵심전달]
slots:
- required:
- - headers[]
- - rows[][]
+ required: ['headers[]', 'rows[][]']
optional: []
+ schema:
+ cell: {max_lines: 2, font_size: 13, ref_chars: {body: 30, sidebar: 20}, note: '13px, 셀당 1~2줄'}
+ max_rows: {body: 7, sidebar: 5, note: '헤더 제외 행 수'}
+
- id: compare-2col-split
name: 2단 분할 비교표
template: blocks/tables/compare-2col-split.html
height_cost: large
- visual: 파란 헤더(좌/구분/우) + 행별 좌:항목 | 중앙:기준라벨(파란) | 우:항목.
- when: '두 기술의 항목별 상세 비교. 예: GIS vs BIM — 개념/분석/도면/발전
-
- '
- not_for: 'VS 배지 필요 → compare-3col-badge 사용. 범용 데이터 → table-simple-striped 사용.
-
- '
+ visual: 파란 헤더(좌/구분/우) + 행별 좌:항목 | 중앙:기준 라벨(파란) | 우:항목. 상세 비교.
+ when: '두 기술/개념의 항목별 상세 비교. 중앙에 비교 기준 라벨. 예: DX vs BIM — 정의/범위/역할 비교. 원본에 이미 비교표 데이터가 있을 때.'
+ not_for: 'VS 배지 → compare-3col-badge. 범용 데이터 → table-simple-striped. 간단 A vs B(2~3항목) → comparison-2col.'
+ purpose_fit: [핵심전달]
+ zone: full-width-only
slots:
- required:
- - left_title
- - right_title
- - rows[]
+ required: [left_title, right_title, 'rows[]']
optional: []
+ schema:
+ cell: {max_lines: 1, font_size: 13, ref_chars: {body: 30}, note: '13px, 셀당'}
+ max_rows: {body: 7, note: '행 수'}
+
- id: table-simple-striped
name: 범용 줄무늬 테이블
template: blocks/tables/table-simple-striped.html
height_cost: medium
- visual: 진한 남색 헤더 + 줄무늬 행 교차. 첫 열 굵은 글씨. 범용.
- when: '비교가 아닌 일반 데이터 표. 예: 구분/현재/목표/비고, 스펙표, 일정표
-
- '
- not_for: 'A vs B 비교 → compare-3col-badge 사용.
-
- '
+ visual: 진한 남색 헤더 + 줄무늬 행 교차. 첫 열 굵은 글씨. 범용 데이터 표.
+ when: '비교가 아닌 일반 데이터 표. 스펙표, 일정표, 항목 목록. 예: 구분/현재/목표/비고.'
+ not_for: 'A vs B 비교 → compare-3col-badge 또는 compare-2col-split.'
+ purpose_fit: [핵심전달, 근거사례]
slots:
- required:
- - headers[]
- - rows[][]
+ required: ['headers[]', 'rows[][]']
optional: []
+
+# ═══════════════════════════════════════
+# VISUALS (6개) — 시각화/다이어그램
+# ═══════════════════════════════════════
- id: venn-diagram
name: SVG 벤 다이어그램
template: blocks/visuals/venn-diagram.html
height_cost: xlarge
- visual: SVG. 진한 파란 큰 원(동심원 링, 입체감) + 3개 작은 원(주황/민트/골드). 그라데이션+글로우.
- when: '상위-하위 포함 관계. 기술 융합 구조. 예: 건설산업DX 안에 GIS/BIM/디지털트윈 ★ 반드시 단독 배치. 다른 블록과 같은
- zone에 쌓지 마라.
-
- '
- not_for: '텍스트로 관계 설명 가능하면 사용 금지. sidebar(35%) 배치 금지. 높이 300px 미만 금지.
-
- '
+ visual: SVG. 진한 파란 큰 원(중심) + 3~5개 작은 원(주황/민트/골드 등). 그라데이션+글로우. 동적 N-item 지원.
+ when: '상위-하위 포함 관계를 시각화. 기술 융합/포함 구조. 예: DX 안에 GIS/BIM/디지털트윈. relation_type=hierarchy 또는 inclusion일 때. ★ 반드시 단독 배치. 다른 블록과 같은 zone에 쌓으면 공간 부족.'
+ not_for: '텍스트로 관계 설명 가능하면 사용 금지. sidebar(35%) 배치 금지. 높이 300px 미만 금지. 순차 흐름(A→B→C) → process-horizontal. 대등 비교 → compare-pill-pair.'
+ purpose_fit: [핵심전달, 구조시각화]
slots:
- required:
- - center_label
- - items[]
- optional:
- - center_sub
- - description
+ required: [center_label, 'items[]']
+ optional: [center_sub, description]
+
- id: circle-gradient
name: 원형 라벨
template: blocks/visuals/circle-gradient.html
height_cost: compact
visual: 파란 그라데이션 원(190px) + 이중 테두리 + 중앙 흰색 텍스트.
- when: '섹션 전환점 키워드 강조. 아래에 카드/표 올 때 주제 선언.
-
- '
- not_for: '본문 텍스트 → topic-header 계열. 결론 → banner-gradient.
-
- '
+ when: '섹션 전환점에서 키워드를 원형으로 강조. 아래에 카드/표가 올 때 주제 선언.'
+ not_for: '본문 텍스트 → topic-header 계열. 결론 한 줄 → banner-gradient. 단독 사용 비추.'
+ purpose_fit: []
slots:
- required:
- - label
- optional:
- - sub_label
+ required: [label]
+ optional: [sub_label]
+ schema:
+ label: {max_lines: 1, font_size: 22, ref_chars: {body: 6, sidebar: 6}, note: '22px bold white, 원 안'}
+ sub_label: {max_lines: 1, font_size: 12, ref_chars: {body: 15, sidebar: 12}, note: '12px, 원 아래'}
+
- id: compare-pill-pair
name: 둥근 박스 VS
template: blocks/visuals/compare-pill-pair.html
height_cost: compact
visual: 이중 테두리 둥근 박스 2개 나란히 + 'VS'. 하늘색 테두리 + 시안 텍스트.
- when: '2개 개념 시각적 대비 (비교 테이블 위 헤더로). 예: ''DX 협업 프로세스'' VS ''BIM 정보 관리''
-
- '
- not_for: '상세 비교(5행+) → compare-3col-badge 사용. 3개 이상 → card-compare-3col 사용.
-
- '
+ when: '2개 개념 시각적 대비. 비교 테이블 위 헤더로 사용. 예: "DX 협업 프로세스" VS "BIM 정보 관리".'
+ not_for: '상세 비교(5행+) → compare-3col-badge. 3개 이상 → card-compare-3col.'
+ purpose_fit: [핵심전달]
+ zone: full-width-only
slots:
- required:
- - left_label
- - right_label
- optional:
- - left_sub
- - right_sub
+ required: [left_label, right_label]
+ optional: [left_sub, right_sub]
+ schema:
+ left_label: {max_lines: 1, font_size: 18, ref_chars: {body: 10}, note: '18px bold, 350px 필 안'}
+ right_label: {max_lines: 1, font_size: 18, ref_chars: {body: 10}, note: '18px bold, 350px 필 안'}
+
- id: process-horizontal
name: 가로 단계 흐름
template: blocks/visuals/process-horizontal.html
height_cost: medium
- visual: 가로 방향. 파란 원형 번호 + 제목 + 설명(카드). → 화살표.
- when: '논리적 순서를 가로로 (1→2→3→4). 프로세스 흐름.
-
- '
- not_for: '시간 기반(연도) → process-horizontal 사용. 세로 나열 → card-numbered 사용.
-
- '
+ visual: 가로 방향. 파란 원형 번호 + 제목 + 설명(카드). → 화살표 연결.
+ when: '논리적 순서가 있는 단계를 가로로. A→B→C→D 프로세스 흐름. 각 단계에 제목+설명이 필요할 때.'
+ not_for: '독립 사례 나열(순서 없음) → card-icon-desc 또는 dark-bullet-list. 세로 나열 → card-numbered. 간결한 흐름(설명 불필요) → flow-arrow-horizontal.'
+ purpose_fit: [핵심전달, 구조시각화]
slots:
- required:
- - steps[]
+ required: ['steps[]']
optional: []
+
- id: flow-arrow-horizontal
name: 가로 흐름 화살표
template: blocks/visuals/flow-arrow-horizontal.html
height_cost: compact
- visual: SVG. 색상 둥근 캡슐이 가로 나열 + 사이 화살표. 컴팩트.
- when: '기술 발전/전환 흐름을 간결하게. 예: GIS → SPCC → 토공 → BIM
-
- '
- not_for: '각 단계에 설명 필요 → process-horizontal 사용.
-
- '
+ visual: SVG. 색상 둥근 캡슐이 가로 나열 + 사이 화살표. 컴팩트. 각 캡슐 120px 폭.
+ when: '명확한 시간 순서 또는 인과 흐름이 있을 때만 사용. A→B→C 순서가 핵심. 예: GIS→SPCC→토공→BIM (기술 발전 순서). ★ 각 라벨은 8자 이내로 짧아야 함(120px 캡슐 안에 들어가야 함).'
+ not_for: '독립 사례/증거 나열(순서 없음) → dark-bullet-list 또는 card-icon-desc. 정책 문서 나열 → dark-bullet-list. 각 단계에 설명 필요 → process-horizontal. 라벨이 길면(8자 초과) → process-horizontal 또는 card-numbered.'
+ purpose_fit: [구조시각화]
+ zone: full-width-only
slots:
- required:
- - steps[]
+ required: ['steps[]']
optional: []
+ schema:
+ step_label: {max_lines: 1, font_size: 13, ref_chars: {body: 8}, note: '13px bold, 120px 캡슐 안. 8자 이내 필수.'}
+ max_steps: {body: 6, note: '단계 수'}
+
- id: keyword-circle-row
name: 키워드 원형 행
template: blocks/visuals/keyword-circle-row.html
height_cost: medium
- visual: SVG 그라데이션 원 안에 큰 글자(G,S,I,M) + 아래 라벨 + 설명.
- when: '약어 풀이. 핵심 키워드를 원형으로 시각 강조. 예: G(Geographic) + S(Structure) + I(Information)
- + M(Model)
-
- '
- not_for: '아이콘+설명 → card-icon-desc 사용. 용어 정의 → card-icon-desc 사용.
-
- '
+ visual: SVG 그라데이션 원 안에 큰 글자(G,S,I,M 등 약어) + 아래 라벨 + 설명.
+ when: '약어 풀이. 핵심 키워드를 원형으로 시각 강조. 예: G(Geographic) + S(Structure) + I(Information) + M(Model).'
+ not_for: '아이콘+설명 → card-icon-desc. 용어 정의(문장형) → card-numbered. 약어가 아닌 일반 텍스트 → 사용 금지.'
+ purpose_fit: [구조시각화]
slots:
- required:
- - keywords[]
+ required: ['keywords[]']
optional: []
+ schema:
+ letter: {max_lines: 1, font_size: 14, ref_chars: {body: 2, sidebar: 2}, note: '약어 1~2글자'}
+ label: {max_lines: 1, font_size: 14, ref_chars: {body: 10, sidebar: 8}, note: '14px bold, 1줄'}
+ description: {max_lines: 2, font_size: 12, ref_chars: {body: 25, sidebar: 20}, note: '12px, 140px 폭, 2줄'}
+ max_keywords: {body: 5, sidebar: 3, note: '키워드 수'}
+
+# ═══════════════════════════════════════
+# EMPHASIS (10개) — 강조/인용/결론
+# ═══════════════════════════════════════
- id: quote-big-mark
name: 큰따옴표 인용
template: blocks/emphasis/quote-big-mark.html
height_cost: medium
visual: 좌상단 ❝ + 우하단 ❞ 큰따옴표 장식. 연한 배경 박스 + 인용문 + 우측 출처.
- when: '임팩트 있는 문제 제기. 시각적으로 인용임을 명확히.
-
- '
- not_for: '짧은 인용(1~2줄) → quote-question. 질문 형태 → quote-question.
-
- '
+ when: '임팩트 있는 인용문. 문제 제기를 인용 형태로 강조. 출처가 있는 인용.'
+ not_for: '짧은 질문(1~2줄) → quote-question. 결론 한 줄 강조 → banner-gradient. 불릿 나열 → dark-bullet-list.'
+ purpose_fit: [문제제기, 근거사례]
slots:
- required:
- - quote_text
- optional:
- - source
+ required: [quote_text]
+ optional: [source]
+ schema:
+ quote_text: {max_lines: 3, font_size: 16, ref_chars: {body: 120, sidebar: 70}, note: '16px, 큰따옴표 장식 안, 3줄 이내'}
+ source: {max_lines: 1, font_size: 14, ref_chars: {body: 30, sidebar: 20}, note: 'caption, 1줄'}
+
- id: quote-question
name: 질문형 강조
template: blocks/emphasis/quote-question.html
height_cost: medium
visual: 밝은 파란 배경 + 파란 테두리 + 큰 질문 텍스트(22px) + 부연 설명.
- when: '독자에게 질문. 문제 인식 유도, 전환점. 예: ''지금의 방식으로도 가능할까?''
-
- '
- not_for: '인용(출처) → quote-big-mark. 결론 → banner-gradient.
-
- '
+ when: '독자에게 질문을 던져 문제 인식을 유도. 전환점. 예: "지금의 방식으로도 가능할까?"'
+ not_for: '인용(출처 있음) → quote-big-mark. 결론 선언 → banner-gradient. 경고/문제 → callout-warning.'
+ purpose_fit: [문제제기]
slots:
- required:
- - question
- optional:
- - description
+ required: [question]
+ optional: [description]
+ schema:
+ question: {max_lines: 1, font_size: 22, ref_chars: {body: 35, sidebar: 25}, note: '22px bold, 1줄 권장'}
+ description: {max_lines: 3, font_size: 14, ref_chars: {body: 120, sidebar: 80}, note: '14px, 3줄 이내'}
+
- id: comparison-2col
name: 2단 병렬 비교
template: blocks/emphasis/comparison-2col.html
height_cost: medium
- visual: 좌우 2단. 좌 파란 헤더 + 우 빨간 헤더. 중앙 구분선. 서브타이틀+본문.
- when: 'A vs B 직접 비교. 장단점, Before/After.
-
- '
- not_for: '다항목 표(5행+) → compare-3col-badge. 시각 대비 → compare-pill-pair.
-
- '
+ visual: 좌우 2단. 좌 파란 헤더(밑줄) + 우 빨간 헤더(밑줄). 중앙 구분선. 서브타이틀+본문.
+ when: 'A vs B 간단 비교. 2~3개 항목을 좌우로 대비. 장단점, Before/After 등 대비 구조. 예: BIM(하위기술) vs DX(상위개념).'
+ not_for: '다항목 표(5행+) → compare-3col-badge. 결론 한 줄 강조 → banner-gradient. 핵심 메시지 선언 → banner-gradient. footer에서 결론 강조용으로 쓰지 마라.'
+ purpose_fit: [핵심전달]
slots:
- required:
- - left_title
- - left_content
- - right_title
- - right_content
- optional:
- - left_subtitle
- - right_subtitle
+ required: [left_title, left_content, right_title, right_content]
+ optional: [left_subtitle, right_subtitle]
+
- id: banner-gradient
name: 그라데이션 배너
template: blocks/emphasis/banner-gradient.html
height_cost: compact
- visual: 전체 너비 파란 그라데이션 배경(둥근 모서리) + 중앙 흰색 텍스트.
- when: '섹션 구분, 핵심 선언, 강조 문구.
-
- '
- not_for: '하단 결론 → banner-gradient. 인용 → quote 계열.
-
- '
+ visual: 전체 너비 파란 그라데이션 배경(둥근 모서리 8px) + 중앙 흰색 굵은 텍스트(16px) + 선택적 서브텍스트.
+ when: '★ 결론 강조에 가장 적합. 핵심 메시지 한 줄 선언. footer 배치에 최적(compact, 50~60px). 페이지의 "기억해야 할 단 하나의 문장". 예: "BIM은 DX의 기초가 되는 일부분이다. DX ≠ BIM"'
+ not_for: '인용(출처) → quote-big-mark. 긴 설명(3줄+) → callout-solution. A vs B 비교 → comparison-2col.'
+ purpose_fit: [결론강조]
slots:
- required:
- - text
- optional:
- - sub_text
+ required: [text]
+ optional: [sub_text]
+ schema:
+ text: {max_lines: 1, font_size: 16, ref_chars: {body: 38, sidebar: 18}, note: '16px bold white, 1줄'}
+ sub_text: {max_lines: 1, font_size: 12, ref_chars: {body: 50, sidebar: 30}, note: '12px, 1줄'}
+
- id: dark-bullet-list
name: 다크 배경 불릿
template: blocks/emphasis/dark-bullet-list.html
height_cost: medium
- visual: 짙은 남색 배경 + 파란 제목 + 흰 텍스트 불릿. 파란 불릿 마커.
- when: '핵심 포인트를 짙은 배경 위에 강조. 시각적 무게감.
-
- '
- not_for: '밝은 배경 → card-icon-desc 또는 card-numbered.
-
- '
+ visual: 짙은 남색 배경 + 파란 제목 + 흰 텍스트 불릿. 파란 불릿 마커. 시각적 무게감.
+ when: '★ 독립적인 사례/증거/포인트를 나열할 때 적합. 순서 없는 항목을 강조하며 나열. 정책 문서 사례, 근거 자료 나열. 예: 혼용 사례 3건을 각각 독립적으로 제시. 핵심 포인트를 짙은 배경 위에 강조.'
+ not_for: '밝은 배경 → card-icon-desc 또는 card-numbered. 순서가 있는 단계 → card-numbered 또는 process-horizontal. 시각화(다이어그램) → venn-diagram.'
+ purpose_fit: [근거사례, 문제제기, 핵심전달]
slots:
- required:
- - bullets[]
- optional:
- - title
+ required: ['bullets[]']
+ optional: [title]
+ schema:
+ title: {max_lines: 1, font_size: 16, ref_chars: {body: 30, sidebar: 20}, note: '16px bold, 1줄'}
+ bullet_item: {max_lines: 1, font_size: 14, ref_chars: {body: 86, sidebar: 41}, note: '14px, 1불릿 기준'}
+ max_bullets: {body: 5, sidebar: 4, note: '불릿 수'}
+
- id: highlight-strip
name: 강조 분류 스트립
template: blocks/emphasis/highlight-strip.html
height_cost: compact
visual: 가로 색상 구간들. 각 구간에 흰 라벨. 카테고리 색상 분류 바.
- when: '카테고리별 색상 분류를 한 줄로. 예: 상용(회색) | 3rd Party(파랑) | 전문SW(빨강)
-
- '
- not_for: '탭 전환 → tab-label-row. 결론 → banner-gradient.
-
- '
+ when: '카테고리별 색상 분류를 한 줄로. 예: 상용(회색) | 3rd Party(파랑) | 전문SW(빨강).'
+ not_for: '탭 전환 → tab-label-row. 결론 강조 → banner-gradient. 독립 항목 나열 → dark-bullet-list.'
+ purpose_fit: [구조시각화]
slots:
- required:
- - segments[]
+ required: ['segments[]']
optional: []
+ schema:
+ label: {max_lines: 1, font_size: 14, ref_chars: {body: 15, sidebar: 10}, note: '14px bold white, nowrap, 세그먼트당'}
+ max_segments: {body: 4, sidebar: 3, note: '세그먼트 수'}
+
- id: callout-solution
name: 솔루션 콜아웃
template: blocks/emphasis/callout-solution.html
height_cost: medium
visual: 밝은 파란 배경 + 파란 테두리 + 아이콘 + 파란 제목 + 설명 + 출처.
- when: '핵심 해결책, 솔루션, 방향성 강조. 예: ''💡 Solution 제시 포인트''
-
- '
- not_for: '경고/문제 → callout-warning. 인용 → quote 계열.
-
- '
+ when: '핵심 해결책, 솔루션, 방향성을 강조. 예: "💡 Solution 제시 포인트".'
+ not_for: '경고/문제 → callout-warning. 인용 → quote-big-mark. 결론 한 줄 → banner-gradient.'
+ purpose_fit: [핵심전달]
slots:
- required:
- - title
- - description
- optional:
- - icon
- - source
+ required: [title, description]
+ optional: [icon, source]
+ schema:
+ title: {max_lines: 1, font_size: 17, ref_chars: {body: 40, sidebar: 25}, note: '17px bold, 1줄'}
+ description: {max_lines: 4, font_size: 14, ref_chars: {body: 150, sidebar: 90}, note: '14px, 3~4줄'}
+
- id: callout-warning
name: 경고 콜아웃
template: blocks/emphasis/callout-warning.html
height_cost: medium
visual: 연한 빨간 배경 + 빨간 테두리 + 아이콘 + 빨간 제목 + 진한 빨간 설명.
- when: '문제점 지적, 주의사항, 잘못된 접근 경고. 예: ''⚠️ 현재 접근 방식의 한계''
-
- '
- not_for: '해결책 → callout-solution. 인용 → quote 계열.
-
- '
+ when: '문제점 지적, 잘못된 인식 경고, 주의사항. 문제 제기 purpose에 적합. 예: "⚠️ 현재 접근 방식의 한계". 잘못된 관행/오해를 명확히 지적할 때.'
+ not_for: '해결책 → callout-solution. 인용 → quote-big-mark. 결론 → banner-gradient.'
+ purpose_fit: [문제제기]
slots:
- required:
- - title
- - description
- optional:
- - icon
+ required: [title, description]
+ optional: [icon]
+
- id: tab-label-row
name: 탭 라벨 행
template: blocks/emphasis/tab-label-row.html
height_cost: compact
visual: 가로 탭 버튼. 선택됨=색상 배경+흰 텍스트, 나머지=회색. 밝은 바탕.
- when: '카테고리 전환/분류 표시. 예: 제조 | 건축 | [인프라/토목]
-
- '
- not_for: '색상 바 → highlight-strip. 실제 클릭 전환 미지원.
-
- '
+ when: '카테고리 전환/분류 표시. 현재 선택된 항목 강조. 예: 제조 | 건축 | [인프라/토목].'
+ not_for: '색상 바 → highlight-strip. 실제 클릭 전환 미지원.'
+ purpose_fit: []
slots:
- required:
- - tabs[]
+ required: ['tabs[]']
optional: []
+ schema:
+ tab_label: {max_lines: 1, font_size: 14, ref_chars: {body: 10, sidebar: 8}, note: '14px bold, 탭당'}
+ max_tabs: {body: 5, sidebar: 3, note: '탭 수'}
+
- id: divider-text
name: 텍스트 구분선
template: blocks/emphasis/divider-text.html
height_cost: compact
- visual: 좌우 가는 회색 선 + 중앙 작은 회색 텍스트. 시각적 휴식점.
- when: '섹션 구분, 주제 전환점에 가벼운 구분. 예: ── 핵심 요약 ──
-
- '
- not_for: '강한 구분 → section-header-bar. 결론 → banner-gradient.
-
- '
+ visual: 좌우 가는 회색 선 + 중앙 작은 회색 텍스트(13px bold). 시각적 휴식점.
+ when: 'sidebar 영역의 섹션 라벨. 주제 전환점에 가벼운 구분. 예: ── 용어 정의 ──'
+ not_for: '강한 구분 → section-header-bar. 결론 → banner-gradient. body 영역 메인 제목 → topic 계열.'
+ purpose_fit: []
slots:
- required:
- - text
+ required: [text]
optional: []
+
+# ═══════════════════════════════════════
+# MEDIA (5개) — 이미지/사진
+# ═══════════════════════════════════════
- id: image-row-2col
name: 이미지 2열
template: blocks/media/image-row-2col.html
height_cost: large
- visual: 이미지 2장 나란히. 높이 354px. 캡션 선택.
- when: '시공 사진 2장, 현장 비교 나란히.
-
- '
- not_for: '4장 → image-grid-2x2. 이미지+텍스트 → image-side-text. 1장 → image-full-caption.
-
- '
+ visual: 이미지 2장 나란히. 각 캡션 선택.
+ when: '시공 사진 2장 나란히, 현장 비교.'
+ not_for: '4장 → image-grid-2x2. 이미지+텍스트 → image-side-text. 1장 → image-full-caption.'
+ purpose_fit: [근거사례]
slots:
- required:
- - images[]
+ required: ['images[]']
optional: []
+
- id: image-grid-2x2
name: 이미지 2x2 그리드
template: blocks/media/image-grid-2x2.html
height_cost: large
- visual: 이미지 4장 2x2 격자. 높이 200px 각. 캡션 선택.
- when: '현장 사진 4장, 4개 관점 이미지.
-
- '
- not_for: '2장 → image-row-2col. 이미지+텍스트 → image-side-text.
-
- '
+ visual: 이미지 4장 2x2 격자. 각 캡션 선택.
+ when: '현장 사진 4장, 4개 관점 이미지.'
+ not_for: '2장 → image-row-2col. 이미지+텍스트 → image-side-text.'
+ purpose_fit: [근거사례]
slots:
- required:
- - images[]
+ required: ['images[]']
optional: []
+
- id: image-side-text
name: 이미지+텍스트 가로
template: blocks/media/image-side-text.html
height_cost: medium
- visual: 좌측 이미지(320px) + 우측 제목+설명+불릿. 가로 배치.
- when: '이미지에 대한 설명. 제품/시스템 소개.
-
- '
- not_for: '이미지만 → image-row-2col. 여러 장 → image-grid-2x2.
-
- '
+ visual: 좌측 이미지(320px 고정) + 우측 제목+설명+불릿. 가로 배치.
+ when: '이미지에 대한 설명. 제품/시스템 소개. 다이어그램+해설.'
+ not_for: '이미지만 → image-row-2col. 여러 장 → image-grid-2x2.'
+ purpose_fit: [핵심전달, 근거사례]
slots:
- required:
- - image_src
- optional:
- - image_alt
- - title
- - description
- - bullets
+ required: [image_src]
+ optional: [image_alt, title, description, bullets]
+
- id: image-full-caption
name: 전체 너비 이미지
template: blocks/media/image-full-caption.html
height_cost: large
visual: 전체 너비 이미지 1장(둥근 모서리) + 하단 캡션.
- when: '핵심 도표, 대형 다이어그램, 전경 사진을 크게.
-
- '
- not_for: '2장+ → image-row-2col/image-grid-2x2. 이미지+텍스트 → image-side-text.
-
- '
+ when: '핵심 도표, 대형 다이어그램, 전경 사진을 크게.'
+ not_for: '2장+ → image-row-2col/image-grid-2x2. 이미지+텍스트 → image-side-text.'
+ purpose_fit: [핵심전달]
slots:
- required:
- - src
- optional:
- - alt
- - caption
+ required: [src]
+ optional: [alt, caption]
+
- id: image-before-after
name: Before/After 이미지
template: blocks/media/image-before-after.html
height_cost: large
- visual: 좌 Before(회색 라벨) + → 화살표(파란) + 우 After(파란 라벨). 각각 이미지.
- when: '변화 전후 비교. 디지털 전환 전후, 공정 개선.
-
- '
- not_for: '이미지 단순 나열 → image-row-2col. 텍스트 비교 → comparison-2col.
-
- '
+ visual: 좌 Before(회색 라벨) + → 화살표(파란) + 우 After(파란 라벨). 각 이미지 180px.
+ when: '변화 전후 비교. 디지털 전환 전후, 공정 개선 전후.'
+ not_for: '이미지 단순 나열 → image-row-2col. 텍스트 비교 → comparison-2col.'
+ purpose_fit: [핵심전달, 근거사례]
slots:
- required:
- - before_src
- - after_src
- optional:
- - before_label
- - after_label
- - caption
+ required: [before_src, after_src]
+ optional: [before_label, after_label, caption]
+
+# ═══════════════════════════════════════
+# LAYOUTS — 프리셋 레이아웃
+# ═══════════════════════════════════════
layouts:
- id: 65-35
name: 6.5:3.5 좌우 분할
@@ -645,12 +567,4 @@ layouts:
- id: 35-65
name: 3.5:6.5 좌우 분할
grid_columns: 3.5fr 6.5fr
- when: 좌측 요약 + 우측 메인
-- id: 40-60
- name: 4:6 좌우 분할
- grid_columns: 4fr 6fr
- when: 좌측 설명 + 우측 시각화
-- id: 60-40
- name: 6:4 좌우 분할
- grid_columns: 6fr 4fr
- when: 좌측 메인 + 우측 보조
+ when: 좌측 보조 + 우측 메인