- 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>
282 lines
10 KiB
Python
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)}개 블록)"
|
|
)
|