From bc7c08e575ff44710d433e4c6d053c08270833ae Mon Sep 17 00:00:00 2001 From: kyeongmin Date: Tue, 7 Apr 2026 09:20:23 +0900 Subject: [PATCH] =?UTF-8?q?B'=20overflow=20=EB=A3=A8=ED=94=84=20=EB=8F=99?= =?UTF-8?q?=EC=9E=91:=20Kei=20popup=20=EA=B2=B0=EC=A0=95=20=E2=86=92=20?= =?UTF-8?q?=EC=83=81=EB=8B=A8=20=EC=86=8C=EC=A0=9C=EB=AA=A9=EB=A7=8C=20?= =?UTF-8?q?=EC=9C=A0=EC=A7=80=20+=20=ED=95=98=EC=9C=84=EB=B6=88=EB=A6=BF?= =?UTF-8?q?=20=ED=8C=9D=EC=97=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - kei_client: 에스컬레이션 prompt 개선 — 소제목 유지 필수, overflow 영역만 대상 - block_assembler B': 상단 popup_roles 체크 추가 — 소제목만 남기고 하위불릿 제거 - block_assembler: \x01 바이트 수정 (r-string 역참조) - 결과: 03번 top overflow 358px → 143px (루프 2회차에서 popup 반영) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/block_assembler.py | 14 +- src/block_assembler_fixed.py | 1291 ++++++++++++++++++++++++++++++++++ src/kei_client.py | 20 +- 3 files changed, 1313 insertions(+), 12 deletions(-) create mode 100644 src/block_assembler_fixed.py diff --git a/src/block_assembler.py b/src/block_assembler.py index ff44048..da69a33 100644 --- a/src/block_assembler.py +++ b/src/block_assembler.py @@ -1055,11 +1055,17 @@ def _assemble_slide_html_type_b_prime(ctx: "PipelineContext", title_text: str = card_gap = max(3, int(font_size * 0.4)) indent_body = int(font_size * 1.2) + # B': 상단이 popup 대상이면 소제목만 유지, 하위 불릿 제거 + top_is_popup = rn in popup_roles + bullets = "" if len(sections) > 1 and sections[0][0]: for ci, (sec_title, sec_items) in enumerate(sections): bg, text_color = _card_colors[ci % len(_card_colors)] - items_html = "".join( + if top_is_popup: + items_html = "" + else: + items_html = "".join( f'
' f'• {item}
' for item in sec_items @@ -1140,7 +1146,7 @@ def _assemble_slide_html_type_b_prime(ctx: "PipelineContext", title_text: str = if sub_sections_from_norm and bottom_left_role: rn = bottom_left_role[0] sub_title, sub_content = sub_sections_from_norm[0] - sub_content = re.sub(r'\*\*(.+?)\*\*', r'', sub_content) + sub_content = re.sub(r'\*\*(.+?)\*\*', r'\1', sub_content) # 표 렌더링 (normalized.tables에서) table_html_bl = "" @@ -1159,7 +1165,7 @@ def _assemble_slide_html_type_b_prime(ctx: "PipelineContext", title_text: str = bg = "#f8fafc" if ri % 2 == 0 else "#fff" cells = "" for ci_idx, cell in enumerate(row): - cell_clean = re.sub(r'\*\*(.+?)\*\*', r'', str(cell)) + cell_clean = re.sub(r'\*\*(.+?)\*\*', r'\1', str(cell)) c_color = "#1e40af" if ci_idx == 0 else "#475569" c_weight = "600" if ci_idx == 0 else "400" cells += f'
{cell_clean}
' @@ -1213,7 +1219,7 @@ def _assemble_slide_html_type_b_prime(ctx: "PipelineContext", title_text: str = if bottom_right_role and len(sub_sections_from_norm) > 1: rn = bottom_right_role[0] sub_title_br, sub_content_br = sub_sections_from_norm[1] - sub_content_br = re.sub(r'\*\*(.+?)\*\*', r'', sub_content_br) + sub_content_br = re.sub(r'\*\*(.+?)\*\*', r'\1', sub_content_br) bul = "" for line in sub_content_br.split("\n"): diff --git a/src/block_assembler_fixed.py b/src/block_assembler_fixed.py new file mode 100644 index 0000000..fa7eb6f --- /dev/null +++ b/src/block_assembler_fixed.py @@ -0,0 +1,1291 @@ +"""블록 조립 공통 모듈. + +filled, assembled, Stage 2 모두 이 모듈의 함수를 사용. +조립 로직이 한 곳에만 존재하여 수정 사항이 전체에 반영됨. + +입력: PipelineContext (또는 동등한 dict) +출력: 역할별 HTML dict + 슬라이드 전체 HTML + +하드코딩 없음. font_hierarchy, sub_layouts, design_reference_html, structured_text에서 동적으로. +""" +from __future__ import annotations + +import re +import logging +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from src.pipeline_context import PipelineContext + +logger = logging.getLogger(__name__) + +COLORS = {"배경": "#dc2626", "본심": "#2563eb", "첨부": "#16a34a", "결론": "#7c3aed"} +FONT_MAP = {"배경": "bg", "본심": "core", "첨부": "sidebar", "결론": "key_msg"} + + +def assemble_role_html( + role: str, + ctx: "PipelineContext", +) -> tuple[str, set[str]]: + """하나의 역할(배경/본심/첨부/결론)에 대해 블록 디자인 + 텍스트를 조립. + + Returns: + (조립된 HTML, 사용된 CSS set) + """ + ps = ctx.page_structure.roles + info = ps.get(role, {}) + if not isinstance(info, dict): + return "", set() + tids = info.get("topic_ids", []) + if not tids: + return "", set() + + topic_map = {t.id: t for t in ctx.topics} + ref_list = ctx.references.get(role, []) + if not ref_list: + return "", set() + + r0 = ref_list[0] + primary_tid = r0.topic_id if hasattr(r0, 'topic_id') and r0.topic_id else (tids[0] if tids else None) + primary_topic = topic_map.get(primary_tid) + if not primary_topic: + return "", set() + + font_key = FONT_MAP.get(role, "core") + font_size = getattr(ctx.font_hierarchy, font_key, 12) + sub_layouts = ctx.sub_layouts or {} + role_sub = sub_layouts.get(role, {}) + role_scs = role_sub.get("sub_containers", []) + + # #10: V-10 bold 키워드 + enh = ctx.enhancement_result or {} + bold_kw = enh.get("bold_keywords", {}) if isinstance(enh.get("bold_keywords"), dict) else {} + role_bold = bold_kw.get(role, []) + + # ── 블록 디자인 HTML에서 CSS 추출 ── + ref_html = r0.design_reference_html or "" + css_parts = re.findall(r'', ref_html, re.DOTALL) + block_body = re.sub(r'', '', ref_html, flags=re.DOTALL) + block_body = re.sub(r'', '', block_body, flags=re.DOTALL).strip() + + # CSS font-size override (font_hierarchy 기준) + overridden_css = set() + for css in css_parts: + def _override_font(m): + val = float(m.group(1)) + if val > font_size + 2: + return f"font-size: {font_size + 1}px" + elif val > font_size: + return f"font-size: {font_size}px" + return m.group(0) + oc = re.sub(r'font-size:\s*(\d+(?:\.\d+)?)px', _override_font, css) + # gap, padding, number size도 font_size 비례 + oc = re.sub(r'gap:\s*\d+px', f'gap: {max(3, int(font_size * 0.4))}px', oc) + oc = re.sub(r'width:\s*32px;\s*\n\s*height:\s*32px', + f'width: {int(font_size * 2)}px;\n height: {int(font_size * 2)}px', oc) + oc = re.sub(r'padding:\s*12px\s+16px', f'padding: {int(font_size*0.7)}px {int(font_size)}px', oc) + oc = oc.replace('white-space: pre-line', 'white-space: normal') + overridden_css.add(oc) + + # ── structured_text 파싱 (들여쓰기 보존) ── + st = primary_topic.structured_text or primary_topic.source_data or "" + st_lines, popup_titles = _parse_structured_text(st, font_size) + + # ── sub_layouts 기반 판단 ── + has_svg = any(sc.get("name") == "svg" for sc in role_scs) + has_keymsg = any(sc.get("name") == "keymsg" for sc in role_scs) + + # #11: V-9 강조 블록 + emphasis_blocks = enh.get("emphasis_blocks", []) + role_emphasis = "" + for eb in emphasis_blocks: + if eb.get("role") == role: + role_emphasis = eb.get("sentence", "") + break + + # #12: V-7 종속꼭지 텍스트 + is_hier = r0.is_hierarchical if hasattr(r0, 'is_hierarchical') else False + sup_tids = r0.supporting_topic_ids if hasattr(r0, 'supporting_topic_ids') else [] + sub_topics_text = [] + if is_hier and sup_tids: + for st_id in sup_tids: + st_topic = topic_map.get(st_id) + if st_topic: + st_text = st_topic.structured_text or st_topic.source_data or "" + sub_topics_text.append(st_text[:120]) + + # ── 블록 구조별 조립 ── + if "block-callout-warn" in block_body or "block-callout-sol" in block_body: + inner = _assemble_callout(block_body, primary_topic, st_lines, font_size, role_bold, role_emphasis, sub_topics_text) + elif "block-card-num" in block_body: + inner = _assemble_card_numbered(primary_topic, st_lines, font_size, role_scs, role_bold) + elif "block-banner-grad" in block_body: + inner = _assemble_banner(block_body, ctx.analysis.core_message or primary_topic.title) + elif has_svg: + # 실제 이미지 파일이 있는 경우만 SVG 레이아웃 사용 + # slide_images에 실제 이미지가 있는지 확인 + has_real_image = any( + img.get("b64") or img.get("path", "").strip() + for img in (ctx.slide_images or []) + ) + if has_real_image: + inner = _assemble_svg_layout(block_body, primary_topic, st_lines, font_size, role_scs, ctx.analysis.core_message, has_keymsg, ctx.slide_images, bold_keywords=role_bold) + else: + inner = _assemble_generic(primary_topic, st_lines, font_size, has_keymsg, ctx.analysis.core_message, role_scs, bold_keywords=role_bold) + else: + inner = _assemble_generic(primary_topic, st_lines, font_size, has_keymsg, ctx.analysis.core_message, role_scs, bold_keywords=role_bold) + + # V'-1: 팝업 링크를 컨테이너 우측상단에 배치 + popup_html = _popup_links_html(popup_titles, font_size) + if popup_html: + inner = f'
{popup_html}{inner}
' + + return inner, overridden_css + + +def _parse_structured_text(st: str, font_size: float) -> tuple[list[tuple[int, str]], list[str]]: + """structured_text → ([(indent, text)], [팝업 제목 리스트]). + [팝업:]은 텍스트에서 분리하여 별도 리스트로 반환. [이미지:]는 제거. **bold** → .""" + lines = [] + popup_titles = [] + for raw_line in st.split("\n"): + stripped = raw_line.strip() + if not stripped: + continue + indent = 1 if raw_line.startswith(" ") else 0 + + # 마커 처리 (bold 변환 전) + popup_match = re.search(r'\[팝업:\s*([^\]]+)\]', stripped) + if popup_match: + popup_titles.append(popup_match.group(1)) + continue + if re.search(r'\[이미지:', stripped): + continue + + # 마크다운 bold → HTML (마커 처리 후) + stripped = re.sub(r'\*\*(.+?)\*\*', r'\1', stripped) + lines.append((indent, stripped)) + return lines, popup_titles + + +def _apply_bold(text: str, keywords: list[str]) -> str: + """V-10 bold 키워드를 으로 감쌈.""" + for kw in keywords: + if kw in text: + text = text.replace(kw, f"{kw}") + return text + + +def _popup_links_html(popup_titles: list[str], font_size: float) -> str: + """팝업 제목 리스트 → 우측상단 배치용 HTML.""" + if not popup_titles: + return "" + links = " ".join( + f'[{t}→]' + for t in popup_titles + ) + return ( + f'
' + f'{links}
' + ) + + +def _st_lines_to_bullets(st_lines: list[tuple[int, str]], font_size: float, bold_keywords: list[str] | None = None) -> str: + """(indent, text) 리스트를 HTML 불릿으로.""" + bk = bold_keywords or [] + html = "" + for indent, text in st_lines: + clean = _apply_bold(text.lstrip("• "), bk) + if text.startswith("출처:") or clean.startswith("출처:"): + # V'-3: "출처:" 라벨 삭제, 텍스트만 표시 + caption = re.sub(r'^출처:\s*', '', clean) + html += f'
{caption}
\n' + elif indent == 1: + html += f'
{clean}
\n' + else: + html += f'
{clean}
\n' + return html + + +def _assemble_callout(block_body, topic, st_lines, font_size, bold_keywords=None, emphasis="", sub_topics_text=None): + """callout-warning/solution 블록에 텍스트 채움.""" + bk = bold_keywords or [] + desc_html = _st_lines_to_bullets(st_lines, font_size, bold_keywords=bk) + # V-7 종속꼭지 인라인 + sub_html = "" + for st_text in (sub_topics_text or []): + sub_html += ( + f'
{_apply_bold(st_text, bk)}
' + ) + # V-9 강조 블록 + emph_html = "" + if emphasis: + emph_html = ( + f'
' + f'→ {_apply_bold(emphasis, bk)}
' + ) + inner = re.sub(r'
.*?
', + f'
{_apply_bold(topic.title, bk)}
', block_body, flags=re.DOTALL) + inner = re.sub(r'
.*?
', + f'
{desc_html}{sub_html}{emph_html}
', inner, flags=re.DOTALL) + return inner + + +def _assemble_card_numbered(topic, st_lines, font_size, role_scs, bold_keywords=None): + """card-numbered 블록에 카드별 텍스트 채움.""" + # indent=0 주불릿 = 카드 제목, indent=1 = 카드 설명 + cards = [] + current_title = "" + current_descs = [] + for indent, text in st_lines: + clean = text.lstrip("• ") + if indent == 0 and text.startswith("• "): + if current_title: + cards.append((current_title, current_descs)) + current_title = clean + current_descs = [] + else: + current_descs.append(clean) + if current_title: + cards.append((current_title, current_descs)) + + # sidebar 라벨 + label = f'
{topic.title}
' + + bk = bold_keywords or [] + card_gap = max(3, int(font_size * 0.4)) + items_html = "" + for i, (title, descs) in enumerate(cards): + desc_html = "" + for d in descs: + d = _apply_bold(d, bk) + if d.startswith("출처:"): + caption = re.sub(r'^출처:\s*', '', d) + desc_html += f'
{caption}
\n' + else: + desc_html += f'
{d}
\n' + num_size = int(font_size * 2) + items_html += ( + f'
' + f'
{i+1}
' + f'
' + f'
{_apply_bold(title, bk)}
' + f'
{desc_html}
' + f'
\n' + ) + + return f'{label}
{items_html}
' + + +def _assemble_banner(block_body, message): + """banner-gradient 블록에 메시지 채움.""" + inner = re.sub(r'
.*?
', + f'
{message}
', block_body, flags=re.DOTALL) + inner = re.sub(r'
.*?
', '', inner, flags=re.DOTALL) + return inner + + +def _assemble_svg_layout(block_body, topic, st_lines, font_size, role_scs, core_message, has_keymsg, slide_images=None, bold_keywords=None): + """이미지(좌) + 텍스트(우) + key-msg(하단) 레이아웃. 실제 이미지 파일 사용.""" + # 실제 이미지가 있으면 사용, 없으면 빈 placeholder + img_html = "" + if slide_images: + for img in slide_images: + b64 = img.get("b64", "") + if b64: + img_html = f'' + break + + svg_sc = next((sc for sc in role_scs if sc["name"] == "svg"), None) + text_sc = next((sc for sc in role_scs if sc["name"] == "text_and_table"), None) + svg_w = int(svg_sc["width_px"]) if svg_sc else 200 + svg_h = int(svg_sc["height_px"]) if svg_sc else 265 + + # 출처 라인을 이미지 아래 캡션으로 분리 + caption_lines = [] + content_lines = [] + for indent, text in st_lines: + clean = text.lstrip("• ") + if text.startswith("출처:") or clean.startswith("출처:"): + caption_lines.append(re.sub(r'^출처:\s*', '', clean)) + else: + content_lines.append((indent, text)) + + img_caption = "" + if caption_lines: + img_caption = f'
{caption_lines[0]}
' + + bullets = _st_lines_to_bullets(content_lines, font_size, bold_keywords=bold_keywords) + bk = bold_keywords or [] + + keymsg_html = "" + if has_keymsg and core_message: + keymsg_sc = next((sc for sc in role_scs if sc["name"] == "keymsg"), None) + km_h = int(keymsg_sc["height_px"]) if keymsg_sc else 37 + keymsg_html = ( + f'
{_apply_bold(core_message, bk)}
' + ) + + return ( + f'
' + f'
' + f'{_apply_bold(topic.title, bk)}
' + f'
' + f'
{img_html}
{img_caption}
' + f'
{bullets}
' + f'
{keymsg_html}
' + ) + + +def _assemble_generic(topic, st_lines, font_size, has_keymsg, core_message, role_scs, bold_keywords=None): + """기타 블록: 제목 + 불릿.""" + bk = bold_keywords or [] + bullets = _st_lines_to_bullets(st_lines, font_size, bold_keywords=bk) + keymsg_html = "" + if has_keymsg and core_message: + keymsg_sc = next((sc for sc in role_scs if sc["name"] == "keymsg"), None) + km_h = int(keymsg_sc["height_px"]) if keymsg_sc else 37 + keymsg_html = ( + f'
{_apply_bold(core_message, bk)}
' + ) + return ( + f'
' + f'
{_apply_bold(topic.title, bk)}
' + f'{bullets}{keymsg_html}
' + ) + + +def assemble_slide_html(ctx: "PipelineContext", title_text: str = "") -> str: + """전체 슬라이드를 조립하여 HTML 반환. + + filled, assembled, stage_2 모두 이 함수를 호출. + layout_template에 따라 유형 A/B 분기. + """ + if ctx.analysis.layout_template == "B": + return _assemble_slide_html_type_b(ctx, title_text) + if ctx.analysis.layout_template == "B'": + return _assemble_slide_html_type_b_prime(ctx, title_text) + return _assemble_slide_html_type_a(ctx, title_text) + + +def _assemble_slide_html_type_a(ctx: "PipelineContext", title_text: str = "") -> str: + """유형 A 전체 슬라이드 조립 (기존 코드 그대로).""" + from src.fit_verifier import _load_design_tokens + tokens = _load_design_tokens() + pad = tokens["spacing_page"] + header_h = tokens.get("header_height", 66) + gap_block = tokens["spacing_block"] + gap_small = tokens["spacing_small"] + + ratio = ctx.container_ratio + slide_w = tokens.get("slide_width", 1280) + slide_h = tokens.get("slide_height", 720) + inner_w = slide_w - pad * 2 + body_w = int(inner_w * ratio[0] / 100) + sidebar_w = inner_w - body_w - gap_block + + fit = ctx.fit_result or {} + redist = fit.get("redistribution", {}) + + all_css = set() + role_htmls = {} + + for role in ["배경", "본심", "첨부", "결론"]: + html, css = assemble_role_html(role, ctx) + role_htmls[role] = html + all_css.update(css) + + # 좌표 계산 + bg_h = int(redist.get("배경", ctx.containers.get("배경", type("", (), {"height_px": 0})).height_px)) + core_h = int(redist.get("본심", ctx.containers.get("본심", type("", (), {"height_px": 0})).height_px)) + sb_h = int(redist.get("첨부", ctx.containers.get("첨부", type("", (), {"height_px": 0})).height_px)) + concl_h = int(redist.get("결론", ctx.containers.get("결론", type("", (), {"height_px": 0})).height_px)) + + bg_top = pad + header_h + gap_block + core_top = bg_top + bg_h + gap_small + sb_top = bg_top + + # V'-4: after(redistribution 있을 때)에서 결론 바로 위까지 body/sidebar 채움 + if redist: + ft_top = slide_h - pad - concl_h - gap_block + column_bottom = ft_top - gap_block + core_h = column_bottom - core_top + sb_h = column_bottom - sb_top + else: + ft_top = max(core_top + core_h, bg_top + sb_h) + gap_block + + title = title_text or ctx.analysis.title or "" + css_block = "\n".join(all_css) + + return f""" + +
+
{title}
+ +
+배경 ({body_w}x{bg_h}px) +{role_htmls.get("배경", "")}
+ +
+본심 ({body_w}x{core_h}px) +{role_htmls.get("본심", "")}
+ +
+첨부 ({sidebar_w}x{sb_h}px) +{role_htmls.get("첨부", "")}
+ + + +
""" + + +def _assemble_slide_html_type_b(ctx: "PipelineContext", title_text: str = "") -> str: + """유형 B 전체 슬라이드 조립: 상단(top+이미지) + 하단 2분할 + 결론. + + assemble_stage2._assemble_type_b의 로직을 PipelineContext 기반으로 통합. + filled/after 파이프라인에서 호출되어 Selenium 측정 가능한 HTML 생성. + """ + from src.fit_verifier import _load_design_tokens + tokens = _load_design_tokens() + pad = tokens["spacing_page"] + header_h = tokens.get("header_height", 66) + gap_block = tokens["spacing_block"] + gap_small = tokens["spacing_small"] + slide_w = tokens.get("slide_width", 1280) + slide_h = tokens.get("slide_height", 720) + inner_w = slide_w - pad * 2 + + ps = ctx.page_structure.roles + enh = ctx.enhancement_result or {} + bold_kw = enh.get("bold_keywords", {}) if isinstance(enh.get("bold_keywords"), dict) else {} + font_h = ctx.font_hierarchy + font_size = font_h.core + title = title_text or ctx.analysis.title or "" + core_message = ctx.analysis.core_message or "" + 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 + bottom_right_role = None + footer_role = None + + for role_name, info in ps.items(): + if not isinstance(info, dict): + continue + zone = info.get("zone", "") + if zone == "top": + top_role = (role_name, info) + elif zone == "bottom_left": + bottom_left_role = (role_name, info) + elif zone == "bottom_right": + bottom_right_role = (role_name, info) + elif zone == "footer": + footer_role = (role_name, info) + + # ── 좌표 계산 (containers에서 동적으로) ── + footer_ci = ctx.containers.get(footer_role[0]) if footer_role else None + footer_h_px = footer_ci.height_px if footer_ci else 53 + ft_top = slide_h - pad - footer_h_px + + top_ci = ctx.containers.get(top_role[0]) if top_role else None + top_h = top_ci.height_px if top_ci else 200 + top_top = pad + header_h + gap_block + + # 이미지: block_constraints 또는 slide_images에서 판단 + img_constraints = top_ci.block_constraints if top_ci else {} + img_w = img_constraints.get("img_width_px", 0) + has_image = img_constraints.get("has_image", False) + # block_constraints에 has_image가 없어도 slide_images에 b64가 있으면 사용 + if not has_image and slide_images: + has_image = any(img.get("b64") for img in slide_images) + if has_image and img_w <= 0: + # 이미지 폭: top_h * ratio, 최대 45% + first_img = next((img for img in slide_images if img.get("b64")), None) + if first_img: + img_ratio = first_img.get("ratio", 1) + img_w = min(int(top_h * img_ratio), int(inner_w * 0.45)) + + img_h = 0 + img_html = "" + if has_image and slide_images: + for img in slide_images: + b64 = img.get("b64", "") + if b64: + img_ratio = img.get("ratio", 1) + img_h = int(img_w / img_ratio) if img_ratio > 0 else top_h + img_html = f'' + break + + # 하단 + bottom_top = top_top + top_h + gap_small + + # V'-4: 결론 바로 위까지 채움 + fit = ctx.fit_result or {} + redist = fit.get("redistribution", {}) + column_bottom = ft_top - gap_block + bottom_h = column_bottom - bottom_top + bottom_col_w = (inner_w - gap_block) // 2 + + # ── 유틸 ── + def _bold(text: str, role: str) -> str: + for kw in bold_kw.get(role, []): + if kw in text: + text = text.replace(kw, f"{kw}") + return text + + # ── 상단 조립: normalized.sections에서 직접 가져오기 ── + top_html = "" + if top_role: + rn = top_role[0] + topic_title_from_section = "" + top_contents = [] + for s in norm_sections: + if s.get("level") == 3: + break # level=3(소목차) 나오면 상단 끝 + if not topic_title_from_section and s.get("title"): + topic_title_from_section = s["title"] + content = s.get("content", "") + if content: + if s.get("title") and s["title"] != topic_title_from_section: + top_contents.append(f"### {s['title']}") + top_contents.append(content) + all_text = "\n".join(top_contents) + all_text_clean = re.sub(r'\*\*(.+?)\*\*', r'\1', all_text) + + # 팝업 분리 + popup_titles = [] + content_lines = [] + for line in all_text_clean.split("\n"): + stripped = line.strip() + if not stripped: + continue + popup_match = re.search(r'\[팝업:\s*([^\]]+)\]', stripped) + if popup_match: + popup_titles.append(popup_match.group(1)) + continue + if re.search(r'\[이미지:', stripped) or re.match(r'^!\[', stripped): + continue + content_lines.append(stripped) + + popup_html = _popup_links_html(popup_titles, font_size) + + # 소제목(### 또는 D1:) + 불릿(D2:)을 카드형으로 분리 + sections = [] + current_section = ("", []) + for line in content_lines: + if line.startswith("### ") or line.startswith("###"): + if current_section[0] or current_section[1]: + sections.append(current_section) + current_section = (line.lstrip("# ").strip(), []) + elif re.match(r'^D1:\s*', line): + # D1 = 1단 불릿 = 소제목 (카드 제목) + title_text = re.sub(r'^D1:\s*', '', line).lstrip("• ") + if current_section[0] or current_section[1]: + sections.append(current_section) + current_section = (_bold(title_text, rn), []) + elif re.match(r'^D[2-9]:\s*', line): + # D2+ = 하위 불릿 = 본문 + clean = re.sub(r'^D[2-9]:\s*', '', line).lstrip("• ") + if clean.startswith("출처:"): + continue + current_section[1].append(_bold(clean, rn)) + else: + clean = line.lstrip("• ") + if clean.startswith("출처:"): + continue + current_section[1].append(_bold(clean, rn)) + if current_section[0] or current_section[1]: + sections.append(current_section) + + # 카드형 HTML + _card_colors = [ + ("linear-gradient(135deg, #1a365d, #2d3748)", "#e2e8f0"), + ("linear-gradient(135deg, #1e3a2f, #2d4a3e)", "#e2e8f0"), + ("linear-gradient(135deg, #3b1f2b, #4a2d3b)", "#e2e8f0"), + ("linear-gradient(135deg, #2d2b55, #3d3b65)", "#e2e8f0"), + ] + card_pad = int(font_size * 0.6) + card_gap = max(3, int(font_size * 0.4)) + indent_body = int(font_size * 1.2) + + bullets = "" + if len(sections) > 1 and sections[0][0]: + for ci, (sec_title, sec_items) in enumerate(sections): + bg, text_color = _card_colors[ci % len(_card_colors)] + items_html = "".join( + f'
' + f'• {item}
' + for item in sec_items + ) + if sec_title: + bullets += ( + f'
' + f'
{_bold(sec_title, rn)}
' + f'{items_html}
\n' + ) + else: + bullets += items_html + else: + for _, sec_items in sections: + for item in sec_items: + bullets += ( + f'
' + f'• {item}
\n' + ) + + # 이미지 캡션 + img_caption = "" + norm_images = ctx.normalized.images or [] + if norm_images: + img_caption = norm_images[0].get("alt", "") + if not img_caption: + for line in all_text.split("\n"): + stripped = line.strip().lstrip("• ") + if stripped.startswith("출처:"): + img_caption = re.sub(r'^출처:\s*', '', stripped) + break + caption_html = f'
{img_caption}
' if img_caption else "" + + # 이미지 블록 + img_block = "" + if has_image and img_html: + img_block = ( + f'
' + f'
{img_html}
' + f'{caption_html}
' + ) + + topic_title = _bold(topic_title_from_section or rn, rn) + + top_html = ( + f'
' + f'{popup_html}' + f'
{topic_title}
' + f'
' + f'
{bullets}
' + f'{img_block}
' + ) + + # ── 하단: normalized.sections에서 직접 매핑 ── + bottom_title = "" + sub_sections_from_norm = [] + found_level3 = False + for s in norm_sections: + if s.get("level") == 3: + found_level3 = True + sub_sections_from_norm.append((s.get("title", ""), s.get("content", ""))) + # 하단 대목차: level=3 바로 앞의 level=2 + for s in norm_sections: + if s.get("level") == 2: + idx = norm_sections.index(s) + if idx + 1 < len(norm_sections) and norm_sections[idx + 1].get("level") == 3: + bottom_title = s.get("title", "") + break + + bl_indent = int(font_size * 1.2) + + # 하단 좌측 + bl_html = "" + if sub_sections_from_norm and bottom_left_role: + rn = bottom_left_role[0] + sub_title, sub_content = sub_sections_from_norm[0] + sub_content = re.sub(r'\*\*(.+?)\*\*', r'\1', sub_content) + + bul = "" + for line in sub_content.split("\n"): + stripped = line.strip() + if not stripped: + continue + # D마커 제거 + depth별 스타일 + 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("• ") + 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' + + bl_html = ( + f'
' + f'
{_bold(sub_title, rn)}
' + f'
{bul}
' + ) + + # 하단 우측 + 표 요약 + br_html = "" + if bottom_right_role and len(sub_sections_from_norm) > 1: + rn = bottom_right_role[0] + sub_title_br, sub_content_br = sub_sections_from_norm[1] + sub_content_br = re.sub(r'\*\*(.+?)\*\*', r'\1', sub_content_br) + + # 팝업 링크 + popup_link_title = f"{sub_title_br} 바로가기" + popup_html_br = ( + f'
' + f'[{popup_link_title} →]
' + ) + + # 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 = "" + for ts_key, ts_data in table_summaries.items(): + fmt = ts_data.get("format", "text") + if fmt == "table": + cols = ts_data.get("columns", []) + data = ts_data.get("data", []) + col_count = len(cols) + if col_count > 0 and data: + header_cells = "".join( + f'
{c}
' + for c in cols + ) + rows_html = "" + for ri, row in enumerate(data): + bg = "#f8fafc" if ri % 2 == 0 else "#fff" + cells = "" + for ci_idx, cell in enumerate(row): + c_color = "#1e40af" if ci_idx == 0 else "#475569" + c_weight = "600" if ci_idx == 0 else "400" + cells += f'
{_bold(str(cell), rn)}
' + rows_html += f'
{cells}
\n' + table_html_br = ( + f'
' + f'
{header_cells}
' + f'{rows_html}
' + ) + elif fmt == "bullets": + items = ts_data.get("items", []) + table_html_br = "".join( + f'
• {_bold(str(item), rn)}
' + for item in items + ) + elif fmt == "text": + table_html_br = f'
{_bold(str(ts_data.get("summary", "")), rn)}
' + + br_html = ( + f'
' + f'{popup_html_br}' + f'
{_bold(sub_title_br, rn)}
' + f'
{bul}
' + f'{table_html_br}
' + ) + + # ── 결론 ── + footer_html = "" + if footer_role: + rn = footer_role[0] + footer_html = ( + f'
' + f'
{_bold(core_message, rn)}
' + ) + + # ── HTML 조립 ── + _color_palette = ["#2563eb", "#16a34a", "#d97706", "#7c3aed"] + + return f""" + +
+ +
{title}
+ +
+상단 ({inner_w}x{top_h}px) +{top_html}
+ +
+
{_bold(bottom_title, "")}
+
+
+{bl_html}
+
+
+{br_html}
+
+ + + +
""" + + +def _assemble_slide_html_type_b_prime(ctx: "PipelineContext", title_text: str = "") -> str: + """유형 B' 전체 슬라이드 조립: 상단(세로 카드) + 하단 2분할 + 결론. (03번용) + + assemble_stage2._assemble_type_b의 로직을 PipelineContext 기반으로 통합. + filled/after 파이프라인에서 호출되어 Selenium 측정 가능한 HTML 생성. + """ + from src.fit_verifier import _load_design_tokens + tokens = _load_design_tokens() + pad = tokens["spacing_page"] + header_h = tokens.get("header_height", 66) + gap_block = tokens["spacing_block"] + gap_small = tokens["spacing_small"] + slide_w = tokens.get("slide_width", 1280) + slide_h = tokens.get("slide_height", 720) + inner_w = slide_w - pad * 2 + + ps = ctx.page_structure.roles + enh = ctx.enhancement_result or {} + bold_kw = enh.get("bold_keywords", {}) if isinstance(enh.get("bold_keywords"), dict) else {} + font_h = ctx.font_hierarchy + font_size = font_h.core + title = title_text or ctx.analysis.title or "" + core_message = ctx.analysis.core_message or "" + 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 + bottom_right_role = None + footer_role = None + + for role_name, info in ps.items(): + if not isinstance(info, dict): + continue + zone = info.get("zone", "") + if zone == "top": + top_role = (role_name, info) + elif zone == "bottom_left": + bottom_left_role = (role_name, info) + elif zone == "bottom_right": + bottom_right_role = (role_name, info) + elif zone == "footer": + footer_role = (role_name, info) + + # ── 좌표 계산 (containers에서 동적으로) ── + footer_ci = ctx.containers.get(footer_role[0]) if footer_role else None + footer_h_px = footer_ci.height_px if footer_ci else 53 + ft_top = slide_h - pad - footer_h_px + + top_ci = ctx.containers.get(top_role[0]) if top_role else None + top_h = top_ci.height_px if top_ci else 200 + top_top = pad + header_h + gap_block + + # 이미지: block_constraints 또는 slide_images에서 판단 + img_constraints = top_ci.block_constraints if top_ci else {} + img_w = img_constraints.get("img_width_px", 0) + has_image = img_constraints.get("has_image", False) + # block_constraints에 has_image가 없어도 slide_images에 b64가 있으면 사용 + if not has_image and slide_images: + has_image = any(img.get("b64") for img in slide_images) + if has_image and img_w <= 0: + # 이미지 폭: top_h * ratio, 최대 45% + first_img = next((img for img in slide_images if img.get("b64")), None) + if first_img: + img_ratio = first_img.get("ratio", 1) + img_w = min(int(top_h * img_ratio), int(inner_w * 0.45)) + + img_h = 0 + img_html = "" + if has_image and slide_images: + for img in slide_images: + b64 = img.get("b64", "") + if b64: + img_ratio = img.get("ratio", 1) + img_h = int(img_w / img_ratio) if img_ratio > 0 else top_h + img_html = f'' + break + + # 하단 + bottom_top = top_top + top_h + gap_small + + # V'-4: 결론 바로 위까지 채움 + fit = ctx.fit_result or {} + redist = fit.get("redistribution", {}) + column_bottom = ft_top - gap_block + bottom_h = column_bottom - bottom_top + bottom_col_w = (inner_w - gap_block) // 2 + + # ── 유틸 ── + def _bold(text: str, role: str) -> str: + for kw in bold_kw.get(role, []): + if kw in text: + text = text.replace(kw, f"{kw}") + return text + + # ── 상단 조립: normalized.sections에서 직접 가져오기 ── + top_html = "" + if top_role: + rn = top_role[0] + topic_title_from_section = "" + top_contents = [] + for s in norm_sections: + if s.get("level") == 3: + break # level=3(소목차) 나오면 상단 끝 + if not topic_title_from_section and s.get("title"): + topic_title_from_section = s["title"] + content = s.get("content", "") + if content: + if s.get("title") and s["title"] != topic_title_from_section: + top_contents.append(f"### {s['title']}") + top_contents.append(content) + all_text = "\n".join(top_contents) + all_text_clean = re.sub(r'\*\*(.+?)\*\*', r'\1', all_text) + + # 팝업 분리 + popup_titles = [] + content_lines = [] + for line in all_text_clean.split("\n"): + stripped = line.strip() + if not stripped: + continue + popup_match = re.search(r'\[팝업:\s*([^\]]+)\]', stripped) + if popup_match: + popup_titles.append(popup_match.group(1)) + continue + if re.search(r'\[이미지:', stripped) or re.match(r'^!\[', stripped): + continue + content_lines.append(stripped) + + popup_html = _popup_links_html(popup_titles, font_size) + + # 소제목(### 또는 D1:) + 불릿(D2:)을 카드형으로 분리 + sections = [] + current_section = ("", []) + for line in content_lines: + if line.startswith("### ") or line.startswith("###"): + if current_section[0] or current_section[1]: + sections.append(current_section) + current_section = (line.lstrip("# ").strip(), []) + elif re.match(r'^D1:\s*', line): + # D1 = 1단 불릿 = 소제목 (카드 제목) + title_text = re.sub(r'^D1:\s*', '', line).lstrip("• ") + if current_section[0] or current_section[1]: + sections.append(current_section) + current_section = (_bold(title_text, rn), []) + elif re.match(r'^D[2-9]:\s*', line): + # D2+ = 하위 불릿 = 본문 + clean = re.sub(r'^D[2-9]:\s*', '', line).lstrip("• ") + if clean.startswith("출처:"): + continue + current_section[1].append(_bold(clean, rn)) + else: + clean = line.lstrip("• ") + if clean.startswith("출처:"): + continue + current_section[1].append(_bold(clean, rn)) + if current_section[0] or current_section[1]: + sections.append(current_section) + + # 카드형 HTML + _card_colors = [ + ("linear-gradient(135deg, #1a365d, #2d3748)", "#e2e8f0"), + ("linear-gradient(135deg, #1e3a2f, #2d4a3e)", "#e2e8f0"), + ("linear-gradient(135deg, #3b1f2b, #4a2d3b)", "#e2e8f0"), + ("linear-gradient(135deg, #2d2b55, #3d3b65)", "#e2e8f0"), + ] + card_pad = int(font_size * 0.6) + card_gap = max(3, int(font_size * 0.4)) + indent_body = int(font_size * 1.2) + + # B': 상단이 popup 대상이면 소제목만 유지, 하위 불릿 제거 + top_is_popup = rn in popup_roles + + bullets = "" + if len(sections) > 1 and sections[0][0]: + for ci, (sec_title, sec_items) in enumerate(sections): + bg, text_color = _card_colors[ci % len(_card_colors)] + if top_is_popup: + items_html = "" + else: + items_html = "".join( + f'
' + f'• {item}
' + for item in sec_items + ) + if sec_title: + bullets += ( + f'
' + f'
{_bold(sec_title, rn)}
' + f'{items_html}
\n' + ) + else: + bullets += items_html + else: + for _, sec_items in sections: + for item in sec_items: + bullets += ( + f'
' + f'• {item}
\n' + ) + + # 이미지 캡션 + img_caption = "" + norm_images = ctx.normalized.images or [] + if norm_images: + img_caption = norm_images[0].get("alt", "") + if not img_caption: + for line in all_text.split("\n"): + stripped = line.strip().lstrip("• ") + if stripped.startswith("출처:"): + img_caption = re.sub(r'^출처:\s*', '', stripped) + break + caption_html = f'
{img_caption}
' if img_caption else "" + + # 이미지 블록 + img_block = "" + if has_image and img_html: + img_block = ( + f'
' + f'
{img_html}
' + f'{caption_html}
' + ) + + topic_title = _bold(topic_title_from_section or rn, rn) + + top_html = ( + f'
' + f'{popup_html}' + f'
{topic_title}
' + f'
' + f'
{bullets}
' + f'{img_block}
' + ) + + # ── 하단: normalized.sections에서 직접 매핑 ── + bottom_title = "" + sub_sections_from_norm = [] + found_level3 = False + for s in norm_sections: + if s.get("level") == 3: + found_level3 = True + sub_sections_from_norm.append((s.get("title", ""), s.get("content", ""))) + # 하단 대목차: level=3 바로 앞의 level=2 + for s in norm_sections: + if s.get("level") == 2: + idx = norm_sections.index(s) + if idx + 1 < len(norm_sections) and norm_sections[idx + 1].get("level") == 3: + bottom_title = s.get("title", "") + break + + bl_indent = int(font_size * 1.2) + + # 하단 좌측 — B': normalized.tables가 있으면 표로 렌더링 + norm_tables = ctx.normalized.tables or [] + bl_html = "" + if sub_sections_from_norm and bottom_left_role: + rn = bottom_left_role[0] + sub_title, sub_content = sub_sections_from_norm[0] + sub_content = re.sub(r'\*\*(.+?)\*\*', r'', sub_content) + + # 표 렌더링 (normalized.tables에서) + table_html_bl = "" + if norm_tables: + for table_data in norm_tables: + headers = table_data.get("headers", []) + rows = table_data.get("rows", []) + col_count = len(headers) + if col_count > 0 and rows: + header_cells = "".join( + f'
{c}
' + for c in headers + ) + rows_html = "" + for ri, row in enumerate(rows): + bg = "#f8fafc" if ri % 2 == 0 else "#fff" + cells = "" + for ci_idx, cell in enumerate(row): + cell_clean = re.sub(r'\*\*(.+?)\*\*', r'', str(cell)) + c_color = "#1e40af" if ci_idx == 0 else "#475569" + c_weight = "600" if ci_idx == 0 else "400" + cells += f'
{cell_clean}
' + rows_html += f'
{cells}
\n' + + table_html_bl = ( + f'
' + f'
{header_cells}
' + f'{rows_html}
' + ) + + # 불릿: 표 셀과 중복되는 텍스트 제외 + table_cell_texts = set() + for td in norm_tables: + for h in td.get("headers", []): + table_cell_texts.add(h.strip().lstrip("*").rstrip("*")) + for row in td.get("rows", []): + for cell in row: + table_cell_texts.add(str(cell).strip().lstrip("*").rstrip("*")) + + bul = "" + for line in sub_content.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("• ") + clean_plain = re.sub(r'<[^>]+>', '', clean).strip() + if clean_plain in table_cell_texts or clean_plain == "➠": + continue + 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' + + bl_html = ( + f'
' + f'
{_bold(sub_title, rn)}
' + f'{table_html_bl}' + f'
{bul}
' + ) + + # 하단 우측 — B': 불릿만 (table_summaries 사용 안 함) + br_html = "" + if bottom_right_role and len(sub_sections_from_norm) > 1: + rn = bottom_right_role[0] + sub_title_br, sub_content_br = sub_sections_from_norm[1] + sub_content_br = re.sub(r'\*\*(.+?)\*\*', r'', sub_content_br) + + bul = "" + 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' + + br_html = ( + f'
' + f'
{_bold(sub_title_br, rn)}
' + f'
{bul}
' + ) + + + # ── 결론 ── + footer_html = "" + if footer_role: + rn = footer_role[0] + footer_html = ( + f'
' + f'
{_bold(core_message, rn)}
' + ) + + # ── HTML 조립 ── + _color_palette = ["#2563eb", "#16a34a", "#d97706", "#7c3aed"] + + return f""" + +
+ +
{title}
+ +
+상단 ({inner_w}x{top_h}px) +{top_html}
+ +
+
{_bold(bottom_title, "")}
+
+
+{bl_html}
+
+
+{br_html}
+
+ + + +
""" diff --git a/src/kei_client.py b/src/kei_client.py index ff860b2..e08a090 100644 --- a/src/kei_client.py +++ b/src/kei_client.py @@ -1358,17 +1358,21 @@ KEI_FIT_ESCALATION_PROMPT = """당신은 슬라이드 설계 전문가이다. ## 핵심 원칙 - **텍스트 원문은 절대 수정/삭제/요약하지 않는다.** -- 공간이 부족하면 **팝업으로 분리**하여 원문 전체를 팝업에 넣는다. -- 슬라이드에는 제목 + "바로가기 →" 링크만 남긴다. -- 중요도가 높은 영역의 공간을 우선 확보한다. +- 공간이 부족하면 **하위 불릿(상세 설명)만 팝업으로 분리.** +- **소제목(카드 제목)은 반드시 슬라이드에 유지.** 절대 팝업으로 빼지 않는다. +- 슬라이드에는 소제목 + "바로가기 →" 링크. 팝업에 하위 불릿 원문 전체. +- overflow가 없는 영역은 건드리지 않는다. ## 판단 기준 -- 넘치는 영역 중 중요도가 낮은 콘텐츠를 팝업으로 분리 -- 표 데이터가 큰 경우 → 팝업 분리 1순위 -- 이미 팝업이 있는 콘텐츠 → 슬라이드에서 제거하고 팝업으로 통합 +- overflow가 발생한 영역만 대상. 다른 영역은 결정하지 않는다. +- 해당 영역 내에서 **하위 불릿(상세 설명)만** 팝업 대상. +- 소제목/카드 제목은 슬라이드에 남겨서 구조를 유지. +- 표 데이터가 큰 경우 → 표를 팝업으로 분리하고 요약만 남김. +- 한 번에 1~2개 역할만 결정. 전부 다 팝업으로 빼지 않는다. ## 출력 (JSON만. 설명 없이.) - role에는 반드시 아래 "역할 목록"에 있는 **정확한 역할명**을 사용하라. +- overflow가 발생한 역할만 포함. overflow 없는 역할은 포함하지 마라. ```json { @@ -1376,7 +1380,7 @@ KEI_FIT_ESCALATION_PROMPT = """당신은 슬라이드 설계 전문가이다. { "role": "역할 목록에 있는 정확한 역할명", "action": "popup", - "detail": "팝업으로 분리할 구체적 내용 (어떤 부분을 팝업으로 빼는지)", + "detail": "팝업으로 분리할 구체적 내용 (하위 불릿만. 소제목은 유지)", "reason": "판단 근거 1문장" } ] @@ -1384,7 +1388,7 @@ KEI_FIT_ESCALATION_PROMPT = """당신은 슬라이드 설계 전문가이다. ``` action 종류: -- popup: 상세 내용을 팝업으로 분리하고 슬라이드에는 링크만 남김 +- popup: 하위 불릿(상세 설명)을 팝업으로 분리. 소제목은 슬라이드에 유지. """