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:
@@ -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 = ""
|
||||||
|
|||||||
@@ -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]}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user