Phase N+O: 컨테이너 기반 레이아웃 + Step B 제거 + 전면 정리
- Phase N: catalog 개선, fallback 전면 제거, Kei API 무한 재시도, topic_id 버그 수정 - Phase O: 컨테이너 스펙 계산(비중→px), 블록 스펙 확정, 렌더러 container div - Step B(Sonnet) 제거: Kei(A-2)+코드로 대체. STEP_B_PROMPT/fallback/DOWNGRADE_MAP 삭제 - Selenium: container div 감지 추가 - catalog.yaml: ref_chars 구조 변환 + FAISS 재빌드 - 문서 전면 갱신: README, PROGRESS, IMPROVEMENT, Phase I~O md Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
618
IMPROVEMENT-PHASE-L.md
Normal file
618
IMPROVEMENT-PHASE-L.md
Normal file
@@ -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
|
||||
<!-- 템플릿에서 max-height 적용 -->
|
||||
<div style="max-height: {{ _max_height_px }}px; overflow: hidden;">
|
||||
<!-- 블록 내용 -->
|
||||
</div>
|
||||
```
|
||||
|
||||
**측정 에이전트(방법 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
|
||||
<!-- 블록별 max-height (area 레벨이 아닌 블록 레벨) -->
|
||||
<div style="max-height: {{ _max_height_px }}px; overflow: hidden;">
|
||||
{{ block_html }}
|
||||
</div>
|
||||
```
|
||||
|
||||
**충돌:** 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곳 식별. 실행 방안 상세 확정. |
|
||||
Reference in New Issue
Block a user