XBX-2 완료: overflow 프로세스 정상 동작

- slide_measurer: data URI → 임시파일 방식 (대용량 HTML 측정 가능)
- pipeline: Type B zone 간 재배분 (top↔bottom 공간 이전)
- pipeline: overflow 분기에 top/bottom zone 추가
- kei_client: 에스컬레이션 prompt 개선
  - 텍스트 원문 보존 원칙 명시 (삭제/요약/압축 금지)
  - action을 popup만으로 제한
  - 실제 역할명 목록을 prompt에 전달
- block_assembler: Kei popup 결정 반영 (해당 역할 콘텐츠 → 팝업 링크)

결과: 02번 상단 카드 3개 모두 표시, 하단 우측 표 → 팝업 분리

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-07 07:39:02 +09:00
parent 028f611070
commit 095abdf9af
4 changed files with 95 additions and 39 deletions

View File

@@ -479,6 +479,13 @@ def _assemble_slide_html_type_b(ctx: "PipelineContext", title_text: str = "") ->
slide_images = ctx.slide_images or [] slide_images = ctx.slide_images or []
norm_sections = ctx.normalized.sections 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별 역할 분류 ── # ── zone별 역할 분류 ──
top_role = None top_role = None
bottom_left_role = None bottom_left_role = None
@@ -748,6 +755,15 @@ def _assemble_slide_html_type_b(ctx: "PipelineContext", title_text: str = "") ->
f'<span style="color:#2563eb;font-size:{font_size-2}px;cursor:pointer;">[{popup_link_title} →]</span></div>' f'<span style="color:#2563eb;font-size:{font_size-2}px;cursor:pointer;">[{popup_link_title} →]</span></div>'
) )
# Kei가 이 역할을 popup 대상으로 결정했으면 → 콘텐츠 대신 팝업 링크만
if rn in popup_roles:
bul = (
f'<div style="padding:{gap_small}px;text-align:center;color:#64748b;'
f'font-size:{font_size}px;margin-top:{gap_small*2}px;">'
f'상세 내용은 팝업에서 확인</div>'
)
table_summaries = {} # 표도 팝업으로 이동
else:
# 불릿 # 불릿
table_summaries = enh.get("table_summaries", {}) table_summaries = enh.get("table_summaries", {})
bul = "" bul = ""
@@ -764,10 +780,10 @@ def _assemble_slide_html_type_b(ctx: "PipelineContext", title_text: str = "") ->
clean = stripped.lstrip("- ").lstrip("") clean = stripped.lstrip("- ").lstrip("")
if clean: if clean:
clean = _bold(clean, rn) clean = _bold(clean, rn)
pad = bl_indent * depth _pad = bl_indent * depth
fs = font_size if depth == 1 else font_size - 1 fs = font_size if depth == 1 else font_size - 1
weight = "font-weight:600;" if depth == 1 else "" weight = "font-weight:600;" if depth == 1 else ""
bul += f'<div style="padding-left:{pad}px;font-size:{fs}px;margin-bottom:2px;{weight}">• {clean}</div>\n' bul += f'<div style="padding-left:{_pad}px;font-size:{fs}px;margin-bottom:2px;{weight}">• {clean}</div>\n'
# 표 요약 HTML # 표 요약 HTML
table_html_br = "" table_html_br = ""

View File

@@ -1355,25 +1355,28 @@ JSON으로 응답하라:
KEI_FIT_ESCALATION_PROMPT = """당신은 슬라이드 설계 전문가이다. KEI_FIT_ESCALATION_PROMPT = """당신은 슬라이드 설계 전문가이다.
콘텐츠를 컨테이너에 배치하려 했으나, 일부 영역의 콘텐츠가 공간을 초과한다. 콘텐츠를 컨테이너에 배치하려 했으나, 일부 영역의 콘텐츠가 공간을 초과한다.
재배분을 시도했지만 해결되지 않은 영역이 있다.
콘텐츠의 중요도와 전달 메시지를 기준으로, 어떻게 처리할지 결정하라. ## 핵심 원칙
- **텍스트 원문은 절대 수정/삭제/요약하지 않는다.**
- 공간이 부족하면 **팝업으로 분리**하여 원문 전체를 팝업에 넣는다.
- 슬라이드에는 제목 + "바로가기 →" 링크만 남긴다.
- 중요도가 높은 영역의 공간을 우선 확보한다.
## 판단 기준 ## 판단 기준
- 핵심 메시지(본심)의 공간은 최대한 보장 - 넘치는 영역 중 중요도가 낮은 콘텐츠를 팝업으로 분리
- 배경은 보조 역할 — 간결화 가능 - 표 데이터가 큰 경우 → 팝업 분리 1순위
- 사례/근거는 인라인 축약 또는 팝업 분리 가능 - 이미 팝업이 있는 콘텐츠 → 슬라이드에서 제거하고 팝업으로 통합
- 용어 정의는 sidebar에 맞게 조정 가능
## 출력 (JSON만. 설명 없이.) ## 출력 (JSON만. 설명 없이.)
- role에는 반드시 아래 "역할 목록"에 있는 **정확한 역할명**을 사용하라.
```json ```json
{ {
"decisions": [ "decisions": [
{ {
"role": "배경", "role": "역할 목록에 있는 정확한 역할명",
"action": "merge|inline|popup|trim|restructure", "action": "popup",
"detail": "구체적 지시 (어떤 꼭지를 어떻게)", "detail": "팝업으로 분리할 구체적 내용 (어떤 부분을 팝업으로 빼는지)",
"reason": "판단 근거 1문장" "reason": "판단 근거 1문장"
} }
] ]
@@ -1381,11 +1384,7 @@ KEI_FIT_ESCALATION_PROMPT = """당신은 슬라이드 설계 전문가이다.
``` ```
action 종류: action 종류:
- merge: 여러 꼭지를 하나의 블록 안에서 흐름으로 합침 - popup: 상세 내용을 팝업으로 분리하고 슬라이드에는 링크만 남김
- inline: 사례/근거를 괄호 한 줄로 축약하여 인라인
- popup: 상세 내용을 팝업으로 분리하고 링크만 남김
- trim: 텍스트 분량을 줄임 (max_chars 지정)
- restructure: 컨테이너 구조 자체를 변경 (배경 전체폭 등)
""" """
@@ -1393,6 +1392,7 @@ async def call_kei_fit_escalation(
fit_report: str, fit_report: str,
topics: list[dict], topics: list[dict],
content_summary: str, content_summary: str,
role_names: list[str] | None = None,
) -> dict[str, Any] | None: ) -> dict[str, Any] | None:
"""Phase V: 적합성 검증 실패 시 Kei에게 판단 요청. """Phase V: 적합성 검증 실패 시 Kei에게 판단 요청.
@@ -1414,10 +1414,16 @@ async def call_kei_fit_escalation(
indent=2, 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 = ( prompt = (
KEI_FIT_ESCALATION_PROMPT + "\n\n" KEI_FIT_ESCALATION_PROMPT + "\n\n"
f"## 적합성 검증 결과\n{fit_report}\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]}" f"## 원본 콘텐츠 요약\n{content_summary[:1500]}"
) )

View File

@@ -552,6 +552,10 @@ async def generate_slide(
# body: 배경↔본심 재배분으로 처리 (후속 redistribute에서) # body: 배경↔본심 재배분으로 처리 (후속 redistribute에서)
logger.info(f"[Stage 1.8] body overflow +{excess}px — 재배분 필요") 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 확장 반영) # containers_dict 업데이트 (sidebar 확장 반영)
for role, ci in updated_containers.items(): for role, ci in updated_containers.items():
containers_dict[role] = { containers_dict[role] = {
@@ -572,6 +576,26 @@ async def generate_slide(
) )
fit_analysis = redistribute(fit_analysis, containers_dict) 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: 조정된 컨테이너 ── # ── after: 조정된 컨테이너 ──
for role, ci in updated_containers.items(): 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 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, fit_report=report,
topics=[t.model_dump() for t in context.topics], topics=[t.model_dump() for t in context.topics],
content_summary=context.raw_content[:1500], content_summary=context.raw_content[:1500],
role_names=list(context.page_structure.roles.keys()),
) )
kei_decisions = [] kei_decisions = []
if kei_result: if kei_result:

View File

@@ -135,13 +135,16 @@ def measure_rendered_heights(html: str) -> dict[str, Any]:
) )
driver = None driver = None
tmp_file = None
try: try:
driver = webdriver.Chrome(options=options) driver = webdriver.Chrome(options=options)
# HTML을 data URI로 로드 # HTML을 임시 파일로 저장 후 file:// URI로 로드 (data URI는 대용량 HTML에서 실패)
import urllib.parse import tempfile
encoded = urllib.parse.quote(html) tmp_file = tempfile.NamedTemporaryFile(suffix=".html", delete=False, mode="w", encoding="utf-8")
driver.get(f"data:text/html;charset=utf-8,{encoded}") tmp_file.write(html)
tmp_file.close()
driver.get(f"file:///{tmp_file.name}")
# 폰트 로딩 대기 (Pretendard CDN) # 폰트 로딩 대기 (Pretendard CDN)
try: try:
@@ -169,6 +172,12 @@ def measure_rendered_heights(html: str) -> dict[str, Any]:
driver.quit() driver.quit()
except Exception: except Exception:
pass pass
if tmp_file:
import os
try:
os.unlink(tmp_file.name)
except Exception:
pass
def format_measurement_for_kei( def format_measurement_for_kei(