Files
_Geulbeot/04. design_agent/IMPROVEMENT-PHASE-A.md
kyeongmin 688ddbbb17 04. design_agent 추가 — 콘텐츠 시각 구조화 슬라이드 생성기
5단계 AI 파이프라인:
1. Kei 실장(Opus via Kei API) — 꼭지 추출 + 정보 구조 파악
2. 디자인 팀장 — FAISS 블록 검색 + Opus 추천 + Sonnet 블록 매핑
3. Kei 편집자(Kei API) — 도메인 전문 텍스트 정리
4. 디자인 실무자(Sonnet + Jinja2) — CSS 변수 조정 + HTML 조립
5. 디자인 팀장(Sonnet) — 균형 재검토 (최대 2회 루프)

블록 라이브러리 46개 (6 카테고리) + _legacy 13개
FAISS 블록 검색 (bge-m3, 1024차원)
SVG N개 동적 배치 (cos/sin 좌표 계산)
Pillow 이미지 크기 측정 + base64 인라인
컨테이너 예산 기반 블록 배치 (zone별 높이 px)

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

492 lines
17 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Phase A: 슬라이드 품질 핵심 — 실행 상세
> "프레임에 내용이 안 보인다"의 직접 원인 해결. 8개 항목.
> 원칙: 하드코딩 금지. 모든 판단은 AI 사고. 회귀 금지.
---
## 실행 순서
```
[독립] A-6 (cover→contain), A-7 (table-layout: fixed)
→ A-8 (container query, A-7 후)
→ A-1 (Sonnet 디자인 조정 — 가장 큰 작업)
→ A-2 (HTML 전달), A-3 (shrink), A-4 (rewrite) — 병렬 가능
→ A-5 (overflow 재검토, A-1 완료 후)
```
---
## A-6: object-fit: cover → contain ✅ 완료
### 현재 상태
- `image-row-2col.html:30``object-fit: cover;`
- `image-grid-2x2.html:31``object-fit: cover;`
- cover는 이미지를 crop → CLAUDE.md "이미지를 crop하지 않는다" 위반
### 작업
두 파일에서 `cover``contain` 변경 (CSS 1줄씩)
### 충돌/회귀
- 충돌: 없음. CSS 속성값만 변경
- 회귀: 없음. CLAUDE.md 원칙 복구
- 부작용: contain은 이미지 주변에 빈 공간(letterbox) 가능 → `background: var(--color-bg-subtle)` 추가로 자연스럽게 처리
### 수정 파일
- `templates/blocks/media/image-row-2col.html`
- `templates/blocks/media/image-grid-2x2.html`
### 구현 결과
- `image-row-2col.html:29~31``object-fit: contain; height: 100%; background: var(--color-bg-subtle, #f8fafc);`
- cover → contain, 높이 하드코딩(354px) → 100%(부모 기준), letterbox 배경색 추가
- `image-grid-2x2.html:29~31` — 동일 패턴 적용 (200px 하드코딩도 함께 제거)
---
## A-7: table-layout: fixed ✅ 완료
### 현재 상태
- `compare-3col-badge.html`에 table-layout 미지정
- 열 너비가 내용 길이에 따라 불안정하게 변동
### 작업
```css
.ct-table {
table-layout: fixed;
width: 100%; /* fixed는 width 필수 */
}
```
### 충돌/회귀
- 충돌: 없음. 기존 테이블 스타일에 속성 추가만
- 회귀: 없음. fixed는 열 너비를 균등하게 고정 — 더 안정적
### 수정 파일
- `templates/blocks/tables/compare-3col-badge.html`
### 구현 결과
- `.block-table-figma table``table-layout: fixed;` 추가 (기존 `width: 100%`는 이미 있었음)
---
## A-8: container query 폰트 스케일링 ✅ 완료
### 현재 상태
- 표 셀 폰트 크기 고정 → 좁은 공간(sidebar 35%)에서 텍스트 잘림/넘침
- @container 규칙 없음
### 작업
```css
.block-compare-table {
container-type: inline-size;
}
@container (max-width: 40rem) {
.ct-cell, .ct-header {
font-size: var(--font-caption); /* 0.8rem */
}
}
@container (max-width: 25rem) {
.ct-cell, .ct-header {
font-size: var(--font-small); /* 0.7rem */
}
}
```
### 하드코딩 점검
- `40rem`, `25rem`은 font-size 기반 상대값 (px 고정이 아님)
- `var(--font-caption)`, `var(--font-small)`은 디자인 토큰 → 하드코딩 아님
### 충돌/회귀
- 충돌: 없음. 신규 CSS 규칙 추가
- 회귀: 없음. @container 미지원 브라우저에서는 무시 → 기존과 동일
- 의존성: A-7 (table-layout: fixed) 먼저 적용해야 열 너비가 안정적
### 수정 파일
- `templates/blocks/tables/compare-3col-badge.html`
### 구현 결과
- `.block-table-figma``container-type: inline-size;` 추가
- `@container (max-width: 40rem)` — 테이블/헤더/셀 폰트 축소 + 패딩 축소
- `@container (max-width: 25rem)` — 추가 축소 + badge 패딩 축소
- **추가:** `tr:hover` 제거 (Phase C-2 선행 처리 — CLAUDE.md "호버 효과 금지")
---
## A-1: 4단계 Sonnet 디자인 조정 ✅ 완료
### 현재 상태
- pipeline.py:73에서 `render_slide(layout_concept)` 직접 호출
- 텍스트 양에 맞는 디자인 조정 과정이 없음 → 텍스트 넘침/빈공간 원인
- CLAUDE.md: "디자인 실무자 (Sonnet + Jinja2 + CSS) — 텍스트에 맞게 폰트/여백/박스 조정"
### API 선택
- **Sonnet** (CLAUDE.md "4단계: Anthropic API (Sonnet)")
- 디자인 실무자는 Kei가 아님 — Sonnet이 맞음
### 핵심 아이디어: CSS 변수 cascade
블록 템플릿 20개가 이미 CSS 변수(`var(--font-body)`, `var(--spacing-inner)` 등)를 187회 사용 중.
area div에서 CSS 변수를 override하면 **템플릿 수정 없이** 모든 블록이 자동 조정됨.
```html
<!-- 예시: Sonnet이 body area의 폰트를 줄이기로 결정 -->
<div class="area-body" style="--font-body: 0.85rem; --spacing-inner: 10px;">
{{ block.html }} <!-- 내부 블록들이 자동으로 작은 폰트/여백 적용 -->
</div>
```
### 파이프라인 흐름 변경
```
기존:
3단계 fill_content → 4단계 render_slide → 5단계 review
변경:
3단계 fill_content → [신규] _adjust_design → 4단계 render_slide → 5단계 review
```
### 신규 함수: _adjust_design()
**위치:** pipeline.py
**입력:** layout_concept (data 채워진 상태)
**처리:**
1. 코드가 각 area별 블록 수, 텍스트 총량(글자 수), zone budget_px를 계산
2. Sonnet에게 전달: area별 정보 + 사용 가능한 CSS 변수 목록
3. Sonnet이 area별 CSS 변수 override를 결정하여 JSON 반환
4. layout_concept에 area_styles 저장
**Sonnet 프롬프트 구성:**
```
당신은 디자인 실무자이다. 편집자가 정리한 텍스트가 각 영역에 잘 들어가도록 CSS를 조정한다.
## 원칙
- 텍스트를 자르지 않는다. 디자인이 텍스트에 맞춘다.
- 빈 공간을 방치하지 않는다.
- 조정 가능한 CSS 변수: --font-body, --font-subtitle, --font-caption, --spacing-inner, --spacing-block, --spacing-small
## 각 영역 현황
- body (예산 490px, 너비 65%): 3개 블록, 총 820자
- quote-question: 120자
- topic-header: 200자
- comparison-table: 500자
- sidebar (예산 490px, 너비 35%): 2개 블록, 총 400자
- card-image: 250자
- card-image: 150자
- footer (예산 60px): 1개 블록, 80자
## 출력 (JSON만)
{"area_styles": {"body": "--font-body: 0.85rem; --spacing-inner: 10px;", "sidebar": "", "footer": ""}}
```
**Sonnet 출력 파싱:**
- `area_styles` dict 추출
- 각 area별 CSS 문자열 → layout_concept 페이지에 저장
**실패 시:** area_styles가 빈 dict → style="" → 기존과 동일하게 렌더링 (안전)
### renderer.py 변경
**render_multi_page() 192~197행:**
기존:
```python
pages_rendered.append({
"grid_areas": page.get("grid_areas", "'main'"),
...
"blocks": blocks_grouped,
"page_number": page_idx + 1,
})
```
변경:
```python
# area_styles를 각 grouped block에 주입
area_styles = page.get("area_styles", {})
for grouped_block in blocks_grouped:
grouped_block["style_override"] = area_styles.get(grouped_block["area"], "")
pages_rendered.append({
"grid_areas": page.get("grid_areas", "'main'"),
...
"blocks": blocks_grouped,
"page_number": page_idx + 1,
})
```
### slide-base.html 변경
**45행:**
기존:
```html
<div class="area-{{ block.area }}">
```
변경:
```html
<div class="area-{{ block.area }}" style="{{ block.style_override | default('') }}">
```
### 하드코딩 점검
- CSS 조정값: Sonnet이 결정 → AI 판단 ✅
- CSS 변수 목록: 프롬프트에 "조정 가능한 변수" 안내 → 가이드일 뿐 강제 아님 ✅
- area별 글자 수: 코드가 계산 → 객관적 수치 ✅
- 하드코딩 없음 ✅
### 충돌/회귀
- pipeline.py: render_slide() 전에 삽입. 기존 흐름 안 건드림 ✅
- renderer.py: blocks_grouped에 style_override 키 추가. 기존 키 영향 없음 ✅
- slide-base.html: style 속성 추가. area_styles 없으면 빈 문자열 → 기존 동일 ✅
- 템플릿 수정: 불필요 (CSS 변수 cascade로 자동 적용) ✅
- 회귀: 없음. 실패 시 기존과 동일 동작 ✅
### 수정 파일
- `src/pipeline.py` — _adjust_design() 신규 함수 + generate_slide()에 호출 추가
- `src/renderer.py` — render_multi_page()에서 area_styles 주입
- `templates/slide-base.html` — area div에 style_override 적용
### 구현 결과
- **pipeline.py** `_adjust_design()` 신규 함수 (약 80행):
- 각 area별 block_count, total_chars, budget_px, width_pct, block_types 자동 집계 (코드)
- Sonnet(디자인 실무자)에게 area별 현황 전달 → CSS 변수 override를 JSON으로 반환받음
- 출력: `page["area_styles"] = {"body": "--font-body: 0.85rem; ...", "sidebar": "", ...}`
- 실패 시: `area_styles = {}` → style="" → 기존과 동일 렌더링 (안전)
- **pipeline.py** `generate_slide()` 72행: `_adjust_design()` 호출 삽입 (render_slide 직전)
- **renderer.py** `render_multi_page()` 192~196행: area_styles를 grouped block의 `style_override`에 주입
- **slide-base.html** 45행: `<div class="area-{{ block.area }}" style="{{ block.style_override | default('') }}">`
- **CSS 변수 cascade 방식:** 블록 템플릿 수정 불필요 — 이미 `var(--font-body)` 등 187회 사용 중이므로 area div에서 override하면 자동 적용
---
## A-2: 5단계 HTML을 프롬프트에 전달 ✅ 완료
### 현재 상태
- pipeline.py:103 `_review_balance(html, ...)` — html 파라미터 있지만 141~146행 프롬프트에서 미사용
- 블록별 데이터 길이만 전달 → 시각적 균형 판단 불가
### 작업
`_review_balance()` 프롬프트에 html 전문 포함
```python
user_prompt = (
f"## 1차 조립 HTML\n{html}\n\n"
f"## 블록별 데이터 양\n" + "\n".join(block_summary) + ...
)
```
### 하드코딩 점검
- html 전문 전달 (임의 잘라내기 없음) ✅
- Sonnet 200K context → 전문 전달 가능 ✅
### 충돌/회귀
- 프롬프트 텍스트만 변경. 파싱/함수 시그니처 동일 ✅
- 회귀: 없음 ✅
### 수정 파일
- `src/pipeline.py``_review_balance()` 프롬프트 부분
### 구현 결과
- `_review_balance()` user_prompt에 `f"## 1차 조립 HTML\n{html}\n\n"` 추가 — html 전문 전달
- 시스템 프롬프트에 "5. HTML 구조: 블록이 영역 안에 잘 배치되었는지" 점검 항목 추가
- 출력 형식에 `target_ratio` 필드 추가 (A-3과 연동)
---
## A-3: 5단계 shrink action + 기존 expand 하드코딩 수정 ✅ 완료
### 현재 상태
- shrink: 조건에 없어서 무시됨
- expand: `char_guide * 1.5` 하드코딩 (pipeline.py:186)
- CLAUDE.md: "모든 판단은 사고로. 하드코딩 없음"
### 작업
**1) 5단계 프롬프트 출력 형식 변경**
기존:
```json
{"adjustments": [{"block_area": "...", "action": "expand|shrink|rewrite", "detail": "..."}]}
```
변경:
```json
{"adjustments": [{"block_area": "...", "action": "expand|shrink|rewrite", "target_ratio": 1.4, "detail": "..."}]}
```
→ Sonnet(디자인 팀장)이 **얼마나** 조정할지를 `target_ratio`로 결정
**2) _apply_adjustments() 코드 변경**
```python
for adj in adjustments:
area = adj.get("block_area", "")
action = adj.get("action", "")
ratio = adj.get("target_ratio")
detail = adj.get("detail", "")
for page in layout_concept.get("pages", []):
for block in page.get("blocks", []):
if block.get("area") == area:
if action == "expand" and ratio:
for key in block.get("char_guide", {}):
block["char_guide"][key] = int(block["char_guide"][key] * ratio)
elif action == "shrink" and ratio:
for key in block.get("char_guide", {}):
block["char_guide"][key] = int(block["char_guide"][key] * ratio)
logger.info(f"조정: {area}{action} ×{ratio} ({detail})")
```
→ ratio가 없으면(Sonnet 누락) 조정 안 함 (무동작이 안전)
→ expand/shrink 모두 Sonnet이 결정한 ratio 사용
### 하드코딩 점검
- ratio: Sonnet이 결정 ✅ (기존 `1.5` 하드코딩 제거)
- ratio 없을 때 기본값: 적용 안 함 (하드코딩 기본값 없음) ✅
### 충돌/회귀
- 기존 expand `* 1.5` 제거 → **기존 하드코딩을 수정하는 것이므로 회귀 아님, 개선임**
- 5단계 프롬프트 출력 형식 변경 → `_parse_json()` 파싱에 영향 없음 (JSON 구조)
- Sonnet이 target_ratio를 안 넣으면 → 조정 안 함 → 기존보다 보수적 (안전)
### 수정 파일
- `src/pipeline.py``_review_balance()` 프롬프트 + `_apply_adjustments()` 코드
### 구현 결과
- `_apply_adjustments()` 전면 재작성:
- `ratio = adj.get("target_ratio")` — Sonnet이 결정한 비율 추출
- `action == "expand" and ratio``char_guide[key] * ratio`
- `action == "shrink" and ratio``char_guide[key] * ratio`
- ratio 없으면(Sonnet 누락) 조정 안 함 → 안전
- 기존 `* 1.5` 하드코딩 완전 제거
- `_review_balance()` 프롬프트에 action별 target_ratio 설명 추가
---
## A-4: 5단계 rewrite action ✅ 완료
### 현재 상태
- rewrite가 expand와 같은 조건(`action in ("expand", "rewrite")`)에 들어가지만
- `if action == "expand"` 안에만 실제 로직 → rewrite는 로그만 찍고 끝 (no-op)
### 작업
A-3에서 변경한 코드에 rewrite 분기 추가:
```python
elif action == "rewrite":
if "data" in block:
del block["data"]
block["reason"] = f"재작성: {detail}"
logger.info(f"조정: {area} → rewrite ({detail})")
```
- data 삭제 → fill_content() 재호출(192행) 시 재매칭
### 하드코딩 점검
- 없음 ✅
### 충돌/회귀
- 기존 no-op → 실제 동작으로 변경 (개선, 회귀 아님)
- fill_content 재호출 시 topic_id 매칭으로 다른 블록도 재편집될 수 있음 → 기존 expand도 동일 동작
- 회귀: 없음 ✅
### 수정 파일
- `src/pipeline.py``_apply_adjustments()` (A-3과 같은 함수)
### 구현 결과
- `_apply_adjustments()``elif action == "rewrite"` 분기 추가
- data 삭제 (`del block["data"]`) → fill_content 재호출 시 topic_id로 재매칭
- `block["reason"]` 업데이트 → 편집자에게 재작성 방향 전달
---
## A-5: overflow 정책 재검토 ✅ 완료
### 현재 상태
- base.css:16 `.slide { overflow: hidden }` — 프레임 경계
- base.css:74 `.slide > div { overflow: hidden }` — BF-8에서 추가한 area별 안전망
### 작업
```css
/* base.css:74 변경 */
.slide > div {
overflow: visible; /* hidden → visible: A-1이 사전 조정하므로 잘림 방지 불필요 */
min-height: 0;
min-width: 0;
}
```
`.slide { overflow: hidden }`(16행)은 **유지** — 프레임 바깥은 잘려야 함
### 하드코딩 점검
- 없음 ✅
### 충돌/회귀
- BF-8에서 추가한 `.slide > div { overflow: hidden }` 제거 → BF-8과 방향 다름
- **그러나 BF-8의 overflow: hidden은 "텍스트를 자르지 않는다" 원칙과 충돌하는 임시 조치였음**
- A-1(Sonnet 디자인 조정)이 넘침을 사전 방지 → 안전망 불필요
- **반드시 A-1 완료 후 적용**
### 수정 파일
- `static/base.css`
### 구현 결과
- base.css `.slide > div``overflow: hidden``overflow: visible`
- 주석 추가: "A-1(Sonnet 디자인 조정)이 텍스트 양에 맞게 CSS를 사전 조정하므로, area 레벨에서는 overflow: visible로 텍스트 잘림을 방지한다."
- `.slide { overflow: hidden }`(16행)은 유지 — 프레임 경계 보호
---
## 수정 파일 총괄
| 파일 | 항목 | 변경 성격 |
|------|------|----------|
| `templates/blocks/media/image-row-2col.html` | A-6 | CSS 1줄 변경 |
| `templates/blocks/media/image-grid-2x2.html` | A-6 | CSS 1줄 변경 |
| `templates/blocks/tables/compare-3col-badge.html` | A-7, A-8 | CSS 추가 |
| `src/pipeline.py` | A-1, A-2, A-3, A-4 | 신규 함수 + 기존 함수 수정 |
| `src/renderer.py` | A-1 | area_styles 주입 (3줄) |
| `templates/slide-base.html` | A-1 | style 속성 추가 (1줄) |
| `static/base.css` | A-5 | overflow 변경 (1줄) |
---
## 검증 체크리스트
- [ ] A-6: image-row, image-grid에서 이미지가 crop 안 됨
- [ ] A-7: 테이블 열 너비가 내용과 무관하게 균등
- [ ] A-8: sidebar(35%)에 표가 들어가면 폰트 자동 축소
- [ ] A-1: Sonnet이 area별 CSS 변수 override 출력 → 렌더링에 반영
- [ ] A-1: _adjust_design 실패 시 기존과 동일하게 렌더링 (안전)
- [ ] A-2: 5단계 Sonnet이 HTML 구조를 보고 균형 판단
- [ ] A-3: shrink 시 Sonnet이 결정한 ratio로 char_guide 축소
- [ ] A-3: 기존 expand 1.5 하드코딩 제거됨
- [ ] A-4: rewrite 시 해당 블록 data 삭제 후 재편집
- [ ] A-5: area div에서 텍스트 잘림 없음
---
## 수정 이력
| 날짜 | 내용 |
|------|------|
| 2026-03-25 | 초안. 하드코딩 제거 반영 (A-2 html 전문, A-3 target_ratio, A-8 상대값). |
| 2026-03-25 | Phase A 전체 구현 완료. 검증 통과. |
## 구현 완료 확인
| 항목 | 검증 결과 |
|------|----------|
| A-1 | `_adjust_design()` 함수 존재, pipeline에서 호출, renderer에서 area_styles 주입, slide-base에서 style_override 적용 |
| A-2 | `_review_balance()` 프롬프트에 html 전문 포함, target_ratio 출력 형식 추가 |
| A-3 | shrink action 구현 + expand 하드코딩(×1.5) 제거 → Sonnet target_ratio 기반 |
| A-4 | rewrite action 구현 — data 삭제 + reason 업데이트 + fill_content 재호출 |
| A-5 | `.slide > div { overflow: visible }` — 프레임(.slide)은 hidden 유지 |
| A-6 | image-row, image-grid: cover → contain + 높이 하드코딩 제거 |
| A-7 | table-layout: fixed + width: 100% |
| A-8 | container-type: inline-size + @container (40rem, 25rem) |
| 추가 | compare-3col-badge tr:hover 제거 (Phase C-2 선행 처리) |