Files
C.E.L_Slide_test2/src/slide_measurer.py
kyeongmin b0bcffc0f6 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>
2026-03-27 15:20:51 +09:00

282 lines
10 KiB
Python

"""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)}개 블록)"
)