WIP: overflow 루프 구현 + Selenium 기반 에스컬레이션 트리거

- pipeline: filled→측정→Kei 재판단→재조립 루프 (최대 3회)
- pipeline: Selenium overflow가 있으면 calculate_fit과 무관하게 에스컬레이션
- 문제: build_escalation_report가 Selenium 측정 결과를 포함하지 않아 Kei가 빈 결정 반환
- 다음: content family 기반 범용 파이프라인 설계 필요 (Phase X-C)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-07 08:59:28 +09:00
parent b2a49f55ef
commit 51f61012c3

View File

@@ -517,46 +517,59 @@ async def generate_slide(
# context에 sub_layouts 반영 후 filled 생성
context = context.model_copy(update={"sub_layouts": pre_sub_layouts})
# ── filled: before 컨테이너에 블록+텍스트 채움 → Selenium 측정 ──
# ── filled→측정→Kei 재판단 루프 (최대 3회) ──
kei_decisions = []
updated_containers = dict(context.containers)
MAX_FIT_RETRIES = 3
for fit_round in range(MAX_FIT_RETRIES):
# context에 현재 kei_decisions 반영 (2회차부터 popup 결정이 반영됨)
if kei_decisions:
context = context.model_copy(update={
"enhancement_result": {
**(context.enhancement_result or {}),
"kei_decisions": kei_decisions,
},
"containers": updated_containers,
})
# ── filled: 컨테이너에 블록+텍스트 채움 ──
filled_html = assemble_slide_html(context)
(steps_dir / "stage_1_8_filled.html").write_text(
(steps_dir / f"stage_1_8_filled{'_r'+str(fit_round) if fit_round else ''}.html").write_text(
filled_html.replace('</head><body>', '</head><body>\n'
'<div style="font-size:16px;font-weight:bold;margin-bottom:4px;">'
'Stage 1.8: filled (블록+텍스트 채운 상태)</div>\n'
f'<div style="font-size:16px;font-weight:bold;margin-bottom:4px;">'
f'Stage 1.8: filled (round {fit_round+1}/{MAX_FIT_RETRIES})</div>\n'
'<div style="font-size:11px;color:#666;margin-bottom:8px;">'
'before 컨테이너에 블록+텍스트를 채움. 넘치는 영역 확인.</div>\n', 1),
encoding="utf-8",
)
# ── Selenium 측정 ──
filled_measurement = await asyncio.to_thread(measure_rendered_heights, filled_html)
logger.info(f"[Stage 1.8] filled 측정 완료")
# ── 판단: 넘치는 영역 처리 ──
updated_containers = dict(context.containers) # 복사
logger.info(f"[Stage 1.8] round {fit_round+1} 측정 완료")
# ── overflow 확인 ──
has_overflow = False
for zone_name, zone_data in filled_measurement.get("zones", {}).items():
if zone_data.get("overflowed"):
excess = zone_data.get("excess_px", 0)
scroll_h = zone_data.get("scrollHeight", 0)
has_overflow = True
if zone_name == "sidebar":
# sidebar 예외: 세로 확장 허용
for role, ci in updated_containers.items():
if ci.zone == "sidebar":
new_h = max(ci.height_px, scroll_h + 10) # 여유 10px
new_h = max(ci.height_px, scroll_h + 10)
updated_containers[role] = ci.model_copy(update={"height_px": new_h})
logger.info(f"[Stage 1.8] sidebar 예외 확장: {role} {ci.height_px}px{new_h}px")
logger.info(f"[Stage 1.8] sidebar 확장: {role}{new_h}px")
else:
logger.info(f"[Stage 1.8] {zone_name} overflow +{excess}px")
elif zone_name == "body":
# body: 배경↔본심 재배분으로 처리 (후속 redistribute에서)
logger.info(f"[Stage 1.8] body overflow +{excess}px — 재배분 필요")
if not has_overflow:
logger.info(f"[Stage 1.8] round {fit_round+1}: overflow 없음 — 완료")
break
elif zone_name in ("top", "bottom"):
# Type B: top/bottom overflow → 후속 에스컬레이션에서 Kei 요약 요청
logger.info(f"[Stage 1.8] {zone_name} overflow +{excess}px — 콘텐츠 축소 필요")
# containers_dict 업데이트 (sidebar 확장 반영)
# ── fit 계산 + 재배분 ──
for role, ci in updated_containers.items():
containers_dict[role] = {
"height_px": ci.height_px,
@@ -564,7 +577,6 @@ async def generate_slide(
"zone": ci.zone,
}
# ── fit 계산 + 재배분 (업데이트된 컨테이너 기준) ──
fit_analysis = calculate_fit(
topics=[t.model_dump() for t in context.topics],
page_structure=context.page_structure.roles,
@@ -576,8 +588,7 @@ async def generate_slide(
)
fit_analysis = redistribute(fit_analysis, containers_dict)
# Type B: zone 간 재배분 (top↔bottom)
# redistribute는 같은 zone 내에서만 동작하므로, Type B는 zone 간 여유를 수동 이전
# Type B: zone 간 재배분
if context.analysis.layout_template in ("B", "B'"):
deficit_roles = [(r, rf.shortfall_px) for r, rf in fit_analysis.roles.items() if rf.shortfall_px > 0]
surplus_roles = [(r, abs(rf.shortfall_px)) for r, rf in fit_analysis.roles.items() if rf.shortfall_px < -8]
@@ -594,41 +605,41 @@ async def generate_slide(
share = transferable * (surplus / total_surplus)
old = fit_analysis.redistribution.get(role, fit_analysis.roles[role].allocated_px)
fit_analysis.redistribution[role] = old - share
logger.info(f"[Stage 1.8] Type B zone 간 재배분: {transferable:.0f}px 이전")
logger.info(f"[Stage 1.8] zone 간 재배분: {transferable:.0f}px 이전")
# ── after: 조정된 컨테이너 ──
# 재배분된 컨테이너 크기 적용
for role, ci in updated_containers.items():
new_h = fit_analysis.redistribution.get(role, ci.height_px) if fit_analysis.redistribution else ci.height_px
updated_containers[role] = ci.model_copy(update={"height_px": int(new_h)})
logger.info(f"[Stage 1.8] after: " + ", ".join(
logger.info(f"[Stage 1.8] round {fit_round+1} after: " + ", ".join(
f"{r}={ci.height_px}px" for r, ci in updated_containers.items()
))
# Step 3: Kei 에스컬레이션 (필요 시)
if fit_analysis.needs_escalation:
# ── Kei 에스컬레이션: overflow 있으면 팝업 분리 판단 요청 ──
# calculate_fit의 needs_escalation 또는 Selenium 측정의 실제 overflow
if fit_analysis.needs_escalation or has_overflow:
from src.kei_client import call_kei_fit_escalation
report = build_escalation_report(fit_analysis)
logger.info(f"[Stage 1.8] 에스컬레이션 필요:\n{report}")
logger.info(f"[Stage 1.8] round {fit_round+1} 에스컬레이션 필요")
kei_result = await call_kei_fit_escalation(
fit_report=report,
topics=[t.model_dump() for t in context.topics],
content_summary=context.raw_content[:1500],
role_names=list(context.page_structure.roles.keys()),
)
kei_decisions = []
if kei_result:
kei_decisions = kei_result.get("decisions", [])
logger.info(f"[Stage 1.8] Kei 결정: {len(kei_decisions)}")
for d in kei_decisions:
action = d.get("action", "")
target_role = d.get("role", "")
detail = d.get("detail", "")
logger.info(f"[V-4] {target_role}{action}: {detail}")
if action == "restructure" and target_role in fit_analysis.roles:
fit_analysis = redistribute(fit_analysis, containers_dict)
new_decisions = kei_result.get("decisions", [])
kei_decisions.extend(new_decisions)
logger.info(f"[Stage 1.8] Kei 결정 {len(new_decisions)}건 추가 (누적 {len(kei_decisions)}건)")
for d in new_decisions:
logger.info(f"[V-4] {d.get('role','')}{d.get('action','')}: {d.get('detail','')[:60]}")
else:
kei_decisions = []
logger.warning(f"[Stage 1.8] round {fit_round+1} Kei 응답 없음 — 루프 종료")
break
else:
logger.info(f"[Stage 1.8] round {fit_round+1}: 재배분으로 해결됨")
break
# Step 4: 보강 제안 분석
enhancements = analyze_enhancements(