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:
281
src/slide_measurer.py
Normal file
281
src/slide_measurer.py
Normal file
@@ -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)}개 블록)"
|
||||
)
|
||||
Reference in New Issue
Block a user