diff --git a/src/pipeline.py b/src/pipeline.py
index 3cd7fd5..086e43d 100644
--- a/src/pipeline.py
+++ b/src/pipeline.py
@@ -517,118 +517,129 @@ 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
- filled_html = assemble_slide_html(context)
- (steps_dir / "stage_1_8_filled.html").write_text(
- filled_html.replace('
', '\n'
- ''
- 'Stage 1.8: filled (블록+텍스트 채운 상태)
\n'
- ''
- 'before 컨테이너에 블록+텍스트를 채움. 넘치는 영역 확인.
\n', 1),
- encoding="utf-8",
- )
+ 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_measurement = await asyncio.to_thread(measure_rendered_heights, filled_html)
- logger.info(f"[Stage 1.8] filled 측정 완료")
-
- # ── 판단: 넘치는 영역 처리 ──
- updated_containers = dict(context.containers) # 복사
-
- 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)
-
- 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
- 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")
-
- elif zone_name == "body":
- # body: 배경↔본심 재배분으로 처리 (후속 redistribute에서)
- logger.info(f"[Stage 1.8] body overflow +{excess}px — 재배분 필요")
-
- 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 확장 반영)
- for role, ci in updated_containers.items():
- containers_dict[role] = {
- "height_px": ci.height_px,
- "width_px": ci.width_px,
- "zone": ci.zone,
- }
-
- # ── fit 계산 + 재배분 (업데이트된 컨테이너 기준) ──
- fit_analysis = calculate_fit(
- topics=[t.model_dump() for t in context.topics],
- page_structure=context.page_structure.roles,
- containers=containers_dict,
- references=refs_dict,
- font_hierarchy=font_h,
- normalized=normalized,
- core_message=core_message,
- )
- fit_analysis = redistribute(fit_analysis, containers_dict)
-
- # Type B: zone 간 재배분 (top↔bottom)
- # redistribute는 같은 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]
- if deficit_roles and surplus_roles:
- total_deficit = sum(d for _, d in deficit_roles)
- total_surplus = sum(s for _, s in surplus_roles)
- transferable = min(total_deficit, total_surplus)
- if transferable > 0:
- for role, deficit in deficit_roles:
- share = transferable * (deficit / total_deficit)
- old = fit_analysis.redistribution.get(role, fit_analysis.roles[role].allocated_px)
- fit_analysis.redistribution[role] = old + share
- for role, surplus in surplus_roles:
- 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 이전")
-
- # ── 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(
- f"{r}={ci.height_px}px" for r, ci in updated_containers.items()
- ))
-
- # Step 3: Kei 에스컬레이션 (필요 시)
- if fit_analysis.needs_escalation:
- from src.kei_client import call_kei_fit_escalation
- report = build_escalation_report(fit_analysis)
- logger.info(f"[Stage 1.8] 에스컬레이션 필요:\n{report}")
- 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()),
+ # ── filled: 컨테이너에 블록+텍스트 채움 ──
+ filled_html = assemble_slide_html(context)
+ (steps_dir / f"stage_1_8_filled{'_r'+str(fit_round) if fit_round else ''}.html").write_text(
+ filled_html.replace('', '\n'
+ f''
+ f'Stage 1.8: filled (round {fit_round+1}/{MAX_FIT_RETRIES})
\n'
+ ''
+ 'before 컨테이너에 블록+텍스트를 채움. 넘치는 영역 확인.
\n', 1),
+ encoding="utf-8",
)
- 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)
- else:
- kei_decisions = []
+
+ # ── Selenium 측정 ──
+ filled_measurement = await asyncio.to_thread(measure_rendered_heights, filled_html)
+ 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":
+ for role, ci in updated_containers.items():
+ if ci.zone == "sidebar":
+ 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} → {new_h}px")
+ else:
+ logger.info(f"[Stage 1.8] {zone_name} overflow +{excess}px")
+
+ if not has_overflow:
+ logger.info(f"[Stage 1.8] round {fit_round+1}: overflow 없음 — 완료")
+ break
+
+ # ── fit 계산 + 재배분 ──
+ for role, ci in updated_containers.items():
+ containers_dict[role] = {
+ "height_px": ci.height_px,
+ "width_px": ci.width_px,
+ "zone": ci.zone,
+ }
+
+ fit_analysis = calculate_fit(
+ topics=[t.model_dump() for t in context.topics],
+ page_structure=context.page_structure.roles,
+ containers=containers_dict,
+ references=refs_dict,
+ font_hierarchy=font_h,
+ normalized=normalized,
+ core_message=core_message,
+ )
+ fit_analysis = redistribute(fit_analysis, containers_dict)
+
+ # 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]
+ if deficit_roles and surplus_roles:
+ total_deficit = sum(d for _, d in deficit_roles)
+ total_surplus = sum(s for _, s in surplus_roles)
+ transferable = min(total_deficit, total_surplus)
+ if transferable > 0:
+ for role, deficit in deficit_roles:
+ share = transferable * (deficit / total_deficit)
+ old = fit_analysis.redistribution.get(role, fit_analysis.roles[role].allocated_px)
+ fit_analysis.redistribution[role] = old + share
+ for role, surplus in surplus_roles:
+ 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] zone 간 재배분: {transferable:.0f}px 이전")
+
+ # 재배분된 컨테이너 크기 적용
+ 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] round {fit_round+1} after: " + ", ".join(
+ f"{r}={ci.height_px}px" for r, ci in updated_containers.items()
+ ))
+
+ # ── 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] 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()),
+ )
+ if kei_result:
+ 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:
+ 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(