From 51f61012c3b5eb49d611480380f7c9f7a34ac1af Mon Sep 17 00:00:00 2001 From: kyeongmin Date: Tue, 7 Apr 2026 08:59:28 +0900 Subject: [PATCH] =?UTF-8?q?WIP:=20overflow=20=EB=A3=A8=ED=94=84=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20+=20Selenium=20=EA=B8=B0=EB=B0=98=20?= =?UTF-8?q?=EC=97=90=EC=8A=A4=EC=BB=AC=EB=A0=88=EC=9D=B4=EC=85=98=20?= =?UTF-8?q?=ED=8A=B8=EB=A6=AC=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- src/pipeline.py | 229 +++++++++++++++++++++++++----------------------- 1 file changed, 120 insertions(+), 109 deletions(-) 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(