diff --git a/src/block_assembler.py b/src/block_assembler.py index 25ca9ba..3269f6e 100644 --- a/src/block_assembler.py +++ b/src/block_assembler.py @@ -479,6 +479,13 @@ def _assemble_slide_html_type_b(ctx: "PipelineContext", title_text: str = "") -> slide_images = ctx.slide_images or [] norm_sections = ctx.normalized.sections or [] + # Kei 에스컬레이션 결정: popup 대상 역할 수집 + kei_decisions = enh.get("kei_decisions", []) + popup_roles = set() + for d in kei_decisions: + if d.get("action") == "popup": + popup_roles.add(d.get("role", "")) + # ── zone별 역할 분류 ── top_role = None bottom_left_role = None @@ -748,26 +755,35 @@ def _assemble_slide_html_type_b(ctx: "PipelineContext", title_text: str = "") -> f'[{popup_link_title} →]' ) - # 불릿 - table_summaries = enh.get("table_summaries", {}) - bul = "" - if not table_summaries: - for line in sub_content_br.split("\n"): - stripped = line.strip() - if not stripped: - continue - depth = 1 - dm = re.match(r'^D(\d+):\s*', stripped) - if dm: - depth = int(dm.group(1)) - stripped = re.sub(r'^D\d+:\s*', '', stripped) - clean = stripped.lstrip("- ").lstrip("• ") - if clean: - clean = _bold(clean, rn) - pad = bl_indent * depth - fs = font_size if depth == 1 else font_size - 1 - weight = "font-weight:600;" if depth == 1 else "" - bul += f'
• {clean}
\n' + # Kei가 이 역할을 popup 대상으로 결정했으면 → 콘텐츠 대신 팝업 링크만 + if rn in popup_roles: + bul = ( + f'
' + f'상세 내용은 팝업에서 확인
' + ) + table_summaries = {} # 표도 팝업으로 이동 + else: + # 불릿 + table_summaries = enh.get("table_summaries", {}) + bul = "" + if not table_summaries: + for line in sub_content_br.split("\n"): + stripped = line.strip() + if not stripped: + continue + depth = 1 + dm = re.match(r'^D(\d+):\s*', stripped) + if dm: + depth = int(dm.group(1)) + stripped = re.sub(r'^D\d+:\s*', '', stripped) + clean = stripped.lstrip("- ").lstrip("• ") + if clean: + clean = _bold(clean, rn) + _pad = bl_indent * depth + fs = font_size if depth == 1 else font_size - 1 + weight = "font-weight:600;" if depth == 1 else "" + bul += f'
• {clean}
\n' # 표 요약 HTML table_html_br = "" diff --git a/src/kei_client.py b/src/kei_client.py index 0826a3c..ff860b2 100644 --- a/src/kei_client.py +++ b/src/kei_client.py @@ -1355,25 +1355,28 @@ JSON으로 응답하라: KEI_FIT_ESCALATION_PROMPT = """당신은 슬라이드 설계 전문가이다. 콘텐츠를 컨테이너에 배치하려 했으나, 일부 영역의 콘텐츠가 공간을 초과한다. -재배분을 시도했지만 해결되지 않은 영역이 있다. -콘텐츠의 중요도와 전달 메시지를 기준으로, 어떻게 처리할지 결정하라. +## 핵심 원칙 +- **텍스트 원문은 절대 수정/삭제/요약하지 않는다.** +- 공간이 부족하면 **팝업으로 분리**하여 원문 전체를 팝업에 넣는다. +- 슬라이드에는 제목 + "바로가기 →" 링크만 남긴다. +- 중요도가 높은 영역의 공간을 우선 확보한다. ## 판단 기준 -- 핵심 메시지(본심)의 공간은 최대한 보장 -- 배경은 보조 역할 — 간결화 가능 -- 사례/근거는 인라인 축약 또는 팝업 분리 가능 -- 용어 정의는 sidebar에 맞게 조정 가능 +- 넘치는 영역 중 중요도가 낮은 콘텐츠를 팝업으로 분리 +- 표 데이터가 큰 경우 → 팝업 분리 1순위 +- 이미 팝업이 있는 콘텐츠 → 슬라이드에서 제거하고 팝업으로 통합 ## 출력 (JSON만. 설명 없이.) +- role에는 반드시 아래 "역할 목록"에 있는 **정확한 역할명**을 사용하라. ```json { "decisions": [ { - "role": "배경", - "action": "merge|inline|popup|trim|restructure", - "detail": "구체적 지시 (어떤 꼭지를 어떻게)", + "role": "역할 목록에 있는 정확한 역할명", + "action": "popup", + "detail": "팝업으로 분리할 구체적 내용 (어떤 부분을 팝업으로 빼는지)", "reason": "판단 근거 1문장" } ] @@ -1381,11 +1384,7 @@ KEI_FIT_ESCALATION_PROMPT = """당신은 슬라이드 설계 전문가이다. ``` action 종류: -- merge: 여러 꼭지를 하나의 블록 안에서 흐름으로 합침 -- inline: 사례/근거를 괄호 한 줄로 축약하여 인라인 -- popup: 상세 내용을 팝업으로 분리하고 링크만 남김 -- trim: 텍스트 분량을 줄임 (max_chars 지정) -- restructure: 컨테이너 구조 자체를 변경 (배경 전체폭 등) +- popup: 상세 내용을 팝업으로 분리하고 슬라이드에는 링크만 남김 """ @@ -1393,6 +1392,7 @@ async def call_kei_fit_escalation( fit_report: str, topics: list[dict], content_summary: str, + role_names: list[str] | None = None, ) -> dict[str, Any] | None: """Phase V: 적합성 검증 실패 시 Kei에게 판단 요청. @@ -1414,10 +1414,16 @@ async def call_kei_fit_escalation( indent=2, ) + # 실제 역할명 목록을 prompt에 명시 (Kei가 정확한 역할명을 사용하도록) + role_list_text = "" + if role_names: + role_list_text = f"\n## 역할 목록 (role에 반드시 아래 이름을 사용)\n" + "\n".join(f"- {r}" for r in role_names) + prompt = ( KEI_FIT_ESCALATION_PROMPT + "\n\n" f"## 적합성 검증 결과\n{fit_report}\n\n" - f"## 꼭지 목록\n{topics_desc}\n\n" + f"## 꼭지 목록\n{topics_desc}" + f"{role_list_text}\n\n" f"## 원본 콘텐츠 요약\n{content_summary[:1500]}" ) diff --git a/src/pipeline.py b/src/pipeline.py index 1995ceb..b9a8e14 100644 --- a/src/pipeline.py +++ b/src/pipeline.py @@ -552,6 +552,10 @@ async def generate_slide( # 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] = { @@ -572,6 +576,26 @@ async def generate_slide( ) fit_analysis = redistribute(fit_analysis, containers_dict) + # Type B: zone 간 재배분 (top↔bottom) + # redistribute는 같은 zone 내에서만 동작하므로, Type B는 zone 간 여유를 수동 이전 + if context.analysis.layout_template == "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 @@ -590,6 +614,7 @@ async def generate_slide( 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: diff --git a/src/slide_measurer.py b/src/slide_measurer.py index 135ab48..6aa9e43 100644 --- a/src/slide_measurer.py +++ b/src/slide_measurer.py @@ -135,13 +135,16 @@ def measure_rendered_heights(html: str) -> dict[str, Any]: ) driver = None + tmp_file = 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}") + # HTML을 임시 파일로 저장 후 file:// URI로 로드 (data URI는 대용량 HTML에서 실패) + import tempfile + tmp_file = tempfile.NamedTemporaryFile(suffix=".html", delete=False, mode="w", encoding="utf-8") + tmp_file.write(html) + tmp_file.close() + driver.get(f"file:///{tmp_file.name}") # 폰트 로딩 대기 (Pretendard CDN) try: @@ -169,6 +172,12 @@ def measure_rendered_heights(html: str) -> dict[str, Any]: driver.quit() except Exception: pass + if tmp_file: + import os + try: + os.unlink(tmp_file.name) + except Exception: + pass def format_measurement_for_kei(