"""Phase V — Stage 1.8: 콘텐츠-컨테이너 적합성 검증. 꼭지별 블록 선택 후, 각 컨테이너에 콘텐츠가 실제로 들어가는지 검증. 안 들어가면 재배분 시도 → 그래도 안 되면 Kei 에스컬레이션. 모든 수치는 동적 계산. font-size / font metric / line-height 외 하드코딩 없음. 레이아웃 수치는 tokens.css(디자인 토큰) 또는 catalog.yaml에서 읽어옴. """ from __future__ import annotations import logging import re from dataclasses import dataclass, field from pathlib import Path from typing import Any logger = logging.getLogger(__name__) # ────────────────────────────────────── # 디자인 토큰 로딩 (tokens.css에서) # ────────────────────────────────────── _tokens_cache: dict[str, int] | None = None def _load_design_tokens() -> dict[str, int]: """tokens.css에서 spacing 변수를 읽어옴.""" global _tokens_cache if _tokens_cache is not None: return _tokens_cache tokens_path = Path(__file__).parent.parent / "static" / "tokens.css" if not tokens_path.exists(): raise FileNotFoundError(f"디자인 토큰 파일 없음: {tokens_path}") css = tokens_path.read_text(encoding="utf-8") tokens: dict[str, int] = {} for match in re.finditer(r"--spacing-(\w+):\s*(\d+)px", css): key = f"spacing_{match.group(1)}" tokens[key] = int(match.group(2)) # border-width도 읽기 for match in re.finditer(r"--border-width:\s*(\d+)px", css): tokens["border_width"] = int(match.group(1)) if "border_width" not in tokens: # tokens.css에 --border-width가 있는지 확인 for match in re.finditer(r"--accent-border:\s*(\d+)px", css): tokens["accent_border"] = int(match.group(1)) _tokens_cache = tokens return tokens # ────────────────────────────────────── # 텍스트 높이 추정 (space_allocator 실측 기반) # ────────────────────────────────────── CHAR_WIDTH_RATIO = 0.947 # Pretendard 한글 실측 (font metric — font-size 계열) def estimate_text_height( text_chars: int, font_size: float, available_width: float, line_height_ratio: float = 1.5, ) -> float: """텍스트를 주어진 폭에 넣으면 몇 px 높이가 필요한가.""" if text_chars <= 0: return 0 char_width = font_size * CHAR_WIDTH_RATIO # 최소 폭: spacing_page (슬라이드 패딩) 이상이어야 유효 tokens = _load_design_tokens() min_width = tokens.get("spacing_page", 1) inner_width = max(min_width, available_width) chars_per_line = max(1, int(inner_width / char_width)) total_lines = max(1, -(-text_chars // chars_per_line)) # ceil division return total_lines * (font_size * line_height_ratio) # ────────────────────────────────────── # 블록 오버헤드: 블록 구조별 정확 계산 # ────────────────────────────────────── def estimate_block_overhead(block_id: str, catalog_entry: dict, item_count: int = 1) -> float: """블록의 텍스트 외 오버헤드(padding, 아이콘, 번호, 테두리 등). catalog.yaml의 padding_overhead_px 필드에서 읽음 — 하드코딩 아님. 카드형 블록은 아이템 수에 비례하여 오버헤드 증가. """ # catalog.yaml에서 padding_overhead_px 읽기 base = catalog_entry.get("padding_overhead_px", 0) # catalog에 없으면 0 (오버헤드 없음으로 처리) # 카드형 블록: per-item 오버헤드 (catalog의 값은 1아이템 기준) category = catalog_entry.get("category", "") if category == "cards" and item_count > 1: tokens = _load_design_tokens() card_gap = tokens["spacing_small"] # 카드 간 간격 = --spacing-small return base * item_count + card_gap * (item_count - 1) return base def estimate_image_height( topic_source_data: str, available_width: float, image_sizes: dict[str, dict] | None = None, ) -> float: """이 꼭지에 이미지가 있으면 높이를 추정. source_data에 '[이미지:' 참조가 있는 꼭지만 이미지를 가짐. image_sizes가 있으면 실제 이미지 크기에서 계산, 없으면 SVG 기준 추정. """ if "[이미지:" not in topic_source_data: return 0 # 실제 이미지 크기가 있으면 사용 if image_sizes: for img_name, size_info in image_sizes.items(): if isinstance(size_info, dict) and "width" in size_info and "height" in size_info: orig_w = size_info["width"] orig_h = size_info["height"] # 이미지 디스플레이 폭: 원본과 가용폭 중 작은 값 img_display_width = min(available_width, orig_w) img_height = orig_h * (img_display_width / max(1, orig_w)) return img_height # 이미지 크기 없으면 SVG 다이어그램으로 추정 # catalog.yaml의 해당 블록 min_height_px를 SVG 높이로 사용 # (venn-diagram min_height=300 등 — catalog에서 동적으로 가져옴) from src.block_reference import _load_catalog for b in _load_catalog(): if b.get("category") == "visuals" and b.get("min_height_px", 0) > 0: # 시각화 블록의 min_height를 SVG 추정 높이로 사용 # 실제 배치 시 available_width에 맞춰 조정됨 return min(b["min_height_px"], available_width) # catalog에도 없으면 가용 폭 기준 정사각형 return available_width return img_height def estimate_keymsg_height(core_message: str, font_size: float) -> float: """key-msg 배너 높이. tokens.css의 spacing에서 padding 읽음.""" if not core_message: return 0 tokens = _load_design_tokens() # key-msg padding = --spacing-small (상하) + border (--border-width × 2) padding_v = tokens["spacing_small"] * 2 # 상 + 하 border_w = tokens.get("border_width", tokens.get("accent_border", 1)) border_v = border_w * 2 # 상 + 하 text_h = font_size * 1.4 # line-height (typography constant) return text_h + padding_v + border_v def count_items_in_topic(topic: dict, role: str, block_schema: dict | None = None) -> int: """꼭지의 아이템 수 추정 — 블록 schema 기반. 블록 schema에 max_items/max_cards/max_steps 등 리스트형 슬롯이 있으면 source_data에서 구분 가능한 항목 수를 파싱. 리스트형 슬롯이 없으면 1 반환. 하드코딩 없음: 블록 schema + source_data 패턴으로만 판단. """ source_data = topic.get("source_data", "") # 블록 schema에서 리스트형 슬롯 확인 if block_schema: max_keys = [k for k in block_schema if k.startswith("max_")] if max_keys: # 리스트형 블록 — source_data에서 항목 수 파싱 return _count_items_from_source(source_data) return 1 def _count_items_from_source(source_data: str) -> int: """source_data에서 구분 가능한 항목 수를 파싱. 패턴 우선순위: 1. "이름(설명), 이름(설명)" → 괄호+쉼표로 구분 2. "- 항목\n- 항목" → 줄바꿈+불릿으로 구분 3. "항목, 항목, 항목" → 쉼표로 구분 """ import re # 패턴 1: "이름(설명), 이름(설명)" paren_items = re.findall(r'[^,()]+\([^)]+\)', source_data) if len(paren_items) >= 2: return len(paren_items) # 패턴 2: 줄바꿈 + 불릿 ("- ", "• ", "* ") bullet_lines = [l.strip() for l in source_data.split("\n") if l.strip() and l.strip()[0] in "-•*"] if len(bullet_lines) >= 2: return len(bullet_lines) # 패턴 3: 쉼표 구분 (단, 괄호 안의 쉼표는 제외) # 괄호 안 내용 제거 후 쉼표로 분리 cleaned = re.sub(r'\([^)]*\)', '', source_data) comma_items = [s.strip() for s in cleaned.split(",") if s.strip()] if len(comma_items) >= 2: return len(comma_items) return 1 def get_actual_text_chars( topic: dict, normalized: dict, role: str, ) -> int: """꼭지에 해당하는 실제 텍스트 분량. structured_text(Kei가 원본 85% 보존 구조화)를 우선 사용. 없으면 source_data 길이로 fallback. 하드코딩 키워드 매칭 없음. """ source_data = topic.get("source_data", "") structured_text = topic.get("structured_text", "") # structured_text가 있으면 그 길이가 실제 텍스트 분량 if structured_text: # [팝업:], [이미지:] 마커는 실제 텍스트가 아니므로 제외 import re clean = re.sub(r'\[팝업:\s*[^\]]+\]', '', structured_text) clean = re.sub(r'\[이미지:\s*[^\]]+\]', '', clean) estimated = len(clean.strip()) else: # fallback: source_data 길이 estimated = len(source_data) # 팝업 링크 추가 (본문에는 "상세보기 →" 링크 1줄) popup_link_chars = 0 if "[팝업:" in source_data or "[팝업:" in structured_text: popups = normalized.get("popups", []) text_to_search = structured_text or source_data for p in popups: if p.get("title", "") in text_to_search: popup_link_chars += len(p.get("title", "")) + len("상세보기 →") + 2 estimated += popup_link_chars return estimated # ────────────────────────────────────── # 데이터 클래스 # ────────────────────────────────────── @dataclass class TopicFit: """한 꼭지의 적합성 분석.""" topic_id: int role: str block_id: str text_chars: int text_height_px: float image_height_px: float # SVG/이미지 높이 block_overhead_px: float required_height_px: float # max(text+overhead, image+overhead) 또는 합산 font_size: float item_count: int = 1 # 카드형 아이템 수 has_image: bool = False has_keymsg: bool = False keymsg_height_px: float = 0 @dataclass class RoleFit: """한 역할(영역)의 적합성 분석.""" role: str topic_fits: list[TopicFit] = field(default_factory=list) total_required_px: float = 0 allocated_px: float = 0 gap_between_px: float = 0 # 실행 시 tokens에서 설정 shortfall_px: float = 0 fit_status: str = "OK" # OK / TIGHT / OVERFLOW @dataclass class FitAnalysis: """전체 적합성 분석.""" roles: dict[str, RoleFit] = field(default_factory=dict) can_redistribute: bool = False redistribution: dict[str, float] | None = None needs_escalation: bool = False # ────────────────────────────────────── # V-2: 적합성 검증 메인 # ────────────────────────────────────── def calculate_fit( topics: list[dict[str, Any]], page_structure: dict[str, Any], containers: dict[str, Any], references: dict[str, list[dict[str, Any]]], font_hierarchy: dict[str, float], normalized: dict[str, Any] | None = None, core_message: str = "", ) -> FitAnalysis: """각 컨테이너에 콘텐츠가 들어가는지 정확하게 검증.""" topic_map = {t.get("id"): t for t in topics} if normalized is None: normalized = {} role_font_map = {"본심": "core", "배경": "bg", "첨부": "sidebar", "결론": "key_msg"} role_line_height = {"본심": 1.5, "배경": 1.4, "첨부": 1.4, "결론": 1.3} analysis = FitAnalysis() for role, info in page_structure.items(): if not isinstance(info, dict): continue topic_ids = info.get("topic_ids", []) if not topic_ids: continue container = containers.get(role) if container is None: continue if hasattr(container, "height_px"): allocated_h = container.height_px width_px = container.width_px else: allocated_h = container.get("height_px", 0) width_px = container.get("width_px", 0) font_key = role_font_map.get(role, "core") font_size = font_hierarchy.get(font_key, 12) line_h = role_line_height.get(role, 1.5) # V-1 출력: 꼭지별 블록 리스트 ref_list = references.get(role, []) ref_map = {} # 블록 수평 padding — catalog.yaml의 padding_h_px에서 가져옴 max_h_padding = 0 for r in ref_list: if isinstance(r, dict): ce = r.get("catalog_entry", {}) max_h_padding = max(max_h_padding, ce.get("padding_h_px", 0)) tokens_cw = _load_design_tokens() content_width = max(tokens_cw["spacing_page"], width_px - max_h_padding) for r in ref_list: if isinstance(r, dict): ref_map[r.get("topic_id")] = r topic_count = len(topic_ids) tokens = _load_design_tokens() block_gap = tokens["spacing_small"] # --spacing-small: 블록 간 간격 role_fit = RoleFit(role=role, allocated_px=allocated_h, gap_between_px=block_gap) total_required = 0 for i, tid in enumerate(topic_ids): topic = topic_map.get(tid, {}) source_data = topic.get("source_data", "") ref = ref_map.get(tid, {}) block_id = ref.get("block_id", "unknown") if isinstance(ref, dict) else "unknown" catalog_entry = ref.get("catalog_entry", {}) if isinstance(ref, dict) else {} # ── 1. 실제 텍스트 분량 ── text_chars = get_actual_text_chars(topic, normalized, role) # ── 2. 아이템 수 (블록 schema의 max_* 키 기반) ── block_schema = catalog_entry.get("schema", {}) if isinstance(catalog_entry, dict) else {} item_count = count_items_in_topic(topic, role, block_schema=block_schema) # ── 3. 이미지 높이 (이 꼭지의 source_data에 [이미지:] 참조가 있을 때만) ── image_sizes = {} # analysis.image_sizes가 있으면 전달 img_h = estimate_image_height(source_data, content_width, image_sizes) has_image = img_h > 0 # ── 4. key-msg (본심에만) ── has_keymsg = (role == "본심" and core_message) keymsg_h = estimate_keymsg_height(core_message, font_hierarchy.get("key_msg", 14)) if has_keymsg else 0 # ── 5. 블록 오버헤드 ── overhead = estimate_block_overhead(block_id, catalog_entry, item_count) # ── 6. 텍스트 높이 ── if has_image: # 이미지가 있으면 텍스트는 이미지 옆에 배치 (flex row) # 이미지 최소 폭: catalog의 min_display_width_px (SVG 가독성 기반) tokens = _load_design_tokens() img_text_gap = tokens["spacing_inner"] img_min_width = tokens["spacing_page"] # 기본 최소 for r in ref_list: if isinstance(r, dict): ce = r.get("catalog_entry", {}) w = ce.get("min_display_width_px", 0) if w > img_min_width: img_min_width = w img_display_width = img_min_width text_width = content_width - img_display_width - img_text_gap text_width = max(tokens["spacing_page"], text_width) text_h = estimate_text_height(text_chars, font_size, text_width, line_h) # 본심 높이 = max(이미지, 텍스트) + key-msg + overhead content_h = max(img_h, text_h) required = content_h + keymsg_h + overhead else: text_h = estimate_text_height(text_chars, font_size, content_width, line_h) required = text_h + keymsg_h + overhead # 제목 높이 (첫 번째 꼭지만 — 영역 제목) if i == 0: title_extra = font_size * 1.5 + tokens["spacing_small"] required += title_extra topic_fit = TopicFit( topic_id=tid, role=role, block_id=block_id, text_chars=text_chars, text_height_px=round(text_h, 1), image_height_px=round(img_h, 1), block_overhead_px=round(overhead, 1), required_height_px=round(required, 1), font_size=font_size, item_count=item_count, has_image=has_image, has_keymsg=has_keymsg, keymsg_height_px=round(keymsg_h, 1), ) role_fit.topic_fits.append(topic_fit) total_required += required if i < topic_count - 1: total_required += block_gap role_fit.total_required_px = round(total_required, 1) role_fit.shortfall_px = round(total_required - allocated_h, 1) tokens = _load_design_tokens() tight_threshold = tokens["spacing_block"] # --spacing-block: TIGHT 판정 기준 if role_fit.shortfall_px <= 0: role_fit.fit_status = "OK" elif role_fit.shortfall_px <= tight_threshold: role_fit.fit_status = "TIGHT" else: role_fit.fit_status = "OVERFLOW" analysis.roles[role] = role_fit logger.info( f"[V-2] {role}: 필요={role_fit.total_required_px}px, " f"배정={allocated_h}px, 차이={role_fit.shortfall_px}px → {role_fit.fit_status}" ) return analysis # ────────────────────────────────────── # V-3: 재배분 # ────────────────────────────────────── def build_escalation_report(analysis: FitAnalysis) -> str: """Kei에게 보낼 에스컬레이션 보고서 생성.""" lines = [] for role, rf in analysis.roles.items(): icon = {"OK": "✅", "TIGHT": "⚠️", "OVERFLOW": "❌"}[rf.fit_status] lines.append(f"{icon} {role}: 필요 {rf.total_required_px}px / 배정 {rf.allocated_px}px → {rf.fit_status} (차이 {rf.shortfall_px:+.0f}px)") for tf in rf.topic_fits: parts = [f"텍스트 {tf.text_chars}자→{tf.text_height_px}px"] if tf.has_image: parts.append(f"이미지 {tf.image_height_px}px") if tf.has_keymsg: parts.append(f"key-msg {tf.keymsg_height_px}px") parts.append(f"overhead {tf.block_overhead_px}px") lines.append(f" 꼭지{tf.topic_id} ({tf.block_id}): {', '.join(parts)} → {tf.required_height_px}px") if analysis.redistribution: lines.append("") lines.append("재배분 시도 결과:") for role, new_h in analysis.redistribution.items(): rf = analysis.roles.get(role) if rf: old_h = rf.allocated_px gap = new_h - rf.total_required_px lines.append(f" {role}: {old_h}→{new_h:.0f}px / 필요 {rf.total_required_px}px → {'해결' if gap >= 0 else f'부족 {abs(gap):.0f}px'}") return "\n".join(lines) ROLE_ZONE_MAP = { "본심": "body", "배경": "body", "첨부": "sidebar", "결론": "footer", } def redistribute( analysis: FitAnalysis, containers: dict[str, Any], min_margin_px: float | None = None, ) -> FitAnalysis: """부족 영역에 여유 영역의 공간을 재배분. 같은 zone 내에서만 재배분 가능 (body 안의 배경↔본심). 유형 B: containers의 zone 속성에서 동적으로 매핑. """ zone_roles: dict[str, list[str]] = {} for role in analysis.roles: # containers에 zone 정보가 있으면 그걸 사용, 없으면 ROLE_ZONE_MAP fallback ci = containers.get(role) if ci is not None: zone = ci.get("zone") if isinstance(ci, dict) else getattr(ci, "zone", None) else: zone = None if not zone: zone = ROLE_ZONE_MAP.get(role, "body") if zone not in zone_roles: zone_roles[zone] = [] zone_roles[zone].append(role) # min_margin을 tokens에서 가져옴 if min_margin_px is None: tokens = _load_design_tokens() min_margin_px = tokens["spacing_small"] # --spacing-small redistribution: dict[str, float] = {} all_resolved = True for zone, roles_in_zone in zone_roles.items(): if len(roles_in_zone) < 2: for role in roles_in_zone: rf = analysis.roles[role] redistribution[role] = rf.allocated_px if rf.shortfall_px > 0: all_resolved = False continue deficit_roles = [] surplus_roles = [] for role in roles_in_zone: rf = analysis.roles[role] if rf.shortfall_px > 0: deficit_roles.append((role, rf.shortfall_px)) elif rf.shortfall_px < -min_margin_px: available = abs(rf.shortfall_px) - min_margin_px if available > 0: surplus_roles.append((role, available)) total_deficit = sum(d for _, d in deficit_roles) total_surplus = sum(s for _, s in surplus_roles) if total_deficit <= 0: for role in roles_in_zone: redistribution[role] = analysis.roles[role].allocated_px continue if total_surplus <= 0: for role in roles_in_zone: redistribution[role] = analysis.roles[role].allocated_px all_resolved = False continue transfer = min(total_deficit, total_surplus) for role, deficit in deficit_roles: rf = analysis.roles[role] share = (deficit / total_deficit) * transfer new_height = rf.allocated_px + share redistribution[role] = round(new_height, 1) logger.info(f"[V-3] {role}: {rf.allocated_px}px → {new_height:.0f}px (+{share:.0f}px)") for role, surplus in surplus_roles: rf = analysis.roles[role] share = (surplus / total_surplus) * transfer new_height = rf.allocated_px - share redistribution[role] = round(new_height, 1) logger.info(f"[V-3] {role}: {rf.allocated_px}px → {new_height:.0f}px (-{share:.0f}px)") if total_surplus < total_deficit: all_resolved = False for role, rf in analysis.roles.items(): if role not in redistribution: redistribution[role] = rf.allocated_px analysis.redistribution = redistribution analysis.can_redistribute = all_resolved analysis.needs_escalation = not all_resolved return analysis # ────────────────────────────────────── # V-7~V-10: 콘텐츠 품질 강화 분석 # ────────────────────────────────────── @dataclass class Enhancement: """하나의 개선 제안.""" role: str type: str # "subordinate" | "fill_space" | "emphasis" | "bold_keywords" description: str # Kei에게 보여줄 설명 detail: dict = field(default_factory=dict) # 구체적 데이터 @dataclass class SupplementBlock: """여유 공간에 추가할 보충 블록.""" role: str block_id: str variant: str content_source: str # "popup:DX와 BIM의 구분" 등 estimated_height_px: float available_px: float @dataclass class EnhancementAnalysis: """V-7~V-10 전체 개선 제안 + Kei 확인 후 보충 블록.""" enhancements: list[Enhancement] = field(default_factory=list) supplement_blocks: list[SupplementBlock] = field(default_factory=list) emphasis_blocks: list[dict] = field(default_factory=list) # 강조 블록 정보 bold_keywords: dict[str, list[str]] = field(default_factory=dict) # role → keywords def analyze_enhancements( topics: list[dict[str, Any]], page_structure: dict[str, Any], references: dict[str, list[dict[str, Any]]], analysis: FitAnalysis, normalized: dict[str, Any], core_message: str = "", ) -> EnhancementAnalysis: """재배분 후 콘텐츠 품질 강화 제안을 생성. AI가 분석, Kei가 확인하는 구조. 하드코딩 없음. 모든 판단은 이전 Stage 데이터(topic purpose/layer, fit 결과, popup 내용)에서 동적 도출. """ topic_map = {t.get("id"): t for t in topics} tokens = _load_design_tokens() result = EnhancementAnalysis() for role, ref_list in references.items(): rf = analysis.roles.get(role) if not rf: continue # ── V-7: 종속 꼭지 처리 제안 ── for ref in ref_list: supporting_tids = ref.get("supporting_topic_ids", []) if not supporting_tids: continue for s_tid in supporting_tids: s_topic = topic_map.get(s_tid, {}) s_source = s_topic.get("source_data", "") s_purpose = s_topic.get("purpose", "") # 종속 꼭지의 분량으로 처리 방식 결정 # 팝업 참조가 있고 source_data가 짧으면 → 인라인 has_popup_ref = "[팝업:" in s_source source_len = len(s_source) # 팝업 참조 시 실제 팝업 내용 길이 확인 popup_content_len = 0 popup_title = "" if has_popup_ref: for p in normalized.get("popups", []): if p.get("title", "") in s_source: popup_content_len = len(p.get("content", "")) popup_title = p.get("title", "") break # 판단: 분량 기준은 spacing 값에서 유도 # 인라인 = 1~2줄 분량 → chars_per_line * 2 이내 # chars_per_line은 font_size와 width에서 계산 font_size = rf.topic_fits[0].font_size if rf.topic_fits else 12 # font-size (허용) container_width = rf.allocated_px # 이건 height인데... width가 필요 # 간략 판단: source_data 자체가 짧으면 인라인 if has_popup_ref and source_len < font_size * CHAR_WIDTH_RATIO * 5: # ~5줄 미만 treatment = "inline" desc = f"종속 꼭지{s_tid}({s_purpose}): 팝업 \"{popup_title}\" 참조 ({popup_content_len}자). 본문에는 인라인 1줄 + 링크" elif source_len > font_size * CHAR_WIDTH_RATIO * 10: # ~10줄 이상 treatment = "sub_block" desc = f"종속 꼭지{s_tid}({s_purpose}): 콘텐츠 {source_len}자. 하위 블록으로 분리 권장" else: treatment = "inline" desc = f"종속 꼭지{s_tid}({s_purpose}): 인라인 처리 ({source_len}자)" result.enhancements.append(Enhancement( role=role, type="subordinate", description=desc, detail={ "supporting_topic_id": s_tid, "treatment": treatment, "source_len": source_len, "has_popup": has_popup_ref, "popup_title": popup_title, "popup_content_len": popup_content_len, }, )) # ── V-8: 여유 공간 콘텐츠 보충 ── new_h = analysis.redistribution.get(role, rf.allocated_px) if analysis.redistribution else rf.allocated_px surplus = new_h - rf.total_required_px # 여유 기준: spacing_block 이상이면 의미 있는 여유 if surplus > tokens["spacing_block"]: # 이 영역의 꼭지에 관련 팝업이 있는지 info = page_structure.get(role, {}) topic_ids = info.get("topic_ids", []) if isinstance(info, dict) else [] for tid in topic_ids: topic = topic_map.get(tid, {}) source_data = topic.get("source_data", "") structured_text = topic.get("structured_text", "") search_text = structured_text + " " + source_data if "[팝업:" not in search_text: continue for p in normalized.get("popups", []): p_title = p.get("title", "") p_content = p.get("content", "") if p_title not in search_text: continue # 팝업에 구조화 콘텐츠가 있는지 (표 = |, 목록 = *) has_table = "|" in p_content and p_content.count("|") > 3 has_list = p_content.count("*") > 2 if has_table or has_list: content_type = "표" if has_table else "목록" result.enhancements.append(Enhancement( role=role, type="fill_space", description=f"{role} 여유 {surplus:.0f}px. 팝업 \"{p_title}\"에 {content_type}({len(p_content)}자) 있음. 핵심 요약을 넣을까요?", detail={ "surplus_px": surplus, "popup_title": p_title, "popup_content_len": len(p_content), "content_type": content_type, "has_table": has_table, }, )) # ── V-9: 영역 핵심 결론 강조 블록 ── info = page_structure.get(role, {}) topic_ids = info.get("topic_ids", []) if isinstance(info, dict) else [] for tid in topic_ids: topic = topic_map.get(tid, {}) purpose = topic.get("purpose", "") source_data = topic.get("source_data", "") # 결론적 패턴 감지: purpose가 문제제기이고 텍스트에 "필요", "해야" 등 conclusion_patterns = ["필요", "해야", "되어야", "요구됨", "시급"] structured_text = topic.get("structured_text", "") # structured_text 우선, 없으면 source_data + sections에서 검색 all_text = structured_text if structured_text else source_data if not structured_text: for s in normalized.get("sections", []): s_content = s.get("content", "") if isinstance(s, dict) else "" if any(kw in s_content for kw in source_data.split()[:3]): all_text += " " + s_content break has_conclusion = any(pat in all_text for pat in conclusion_patterns) if has_conclusion and purpose in ("문제제기", "핵심전달"): # 결론 문장 추출: 전체 텍스트에서 패턴 포함 문장 sentences = [s.strip() for s in all_text.replace("\n", ". ").replace(".", ". ").split(". ") if s.strip()] conclusion_sentence = "" for sent in reversed(sentences): if any(pat in sent for pat in conclusion_patterns): conclusion_sentence = sent break if conclusion_sentence: result.enhancements.append(Enhancement( role=role, type="emphasis", description=f"{role} 꼭지{tid}({purpose}): \"{conclusion_sentence[:50]}...\" → 강조 블록으로 처리할까요?", detail={ "topic_id": tid, "conclusion_sentence": conclusion_sentence, "purpose": purpose, }, )) # ── V-10: bold 키워드 — Kei가 문맥 기반으로 판단 (pipeline.py에서 호출) ── # 기계적 키워드 추출 제거. Kei 판단 결과가 pipeline.py에서 주입됨. return result def build_enhancement_report(enhancements: EnhancementAnalysis) -> str: """Kei에게 보여줄 개선 제안 보고서.""" lines = ["=== 콘텐츠 품질 강화 제안 ===", ""] by_type = {} for e in enhancements.enhancements: if e.type not in by_type: by_type[e.type] = [] by_type[e.type].append(e) type_labels = { "subordinate": "V-7 종속 꼭지 처리", "fill_space": "V-8 여유 공간 보충", "emphasis": "V-9 강조 블록", "bold_keywords": "V-10 bold 키워드", } for etype, label in type_labels.items(): items = by_type.get(etype, []) if not items: continue lines.append(f"── {label} ({len(items)}건) ──") for e in items: lines.append(f" [{e.role}] {e.description}") lines.append("") return "\n".join(lines) def apply_enhancements( enhancements: EnhancementAnalysis, analysis: FitAnalysis, ) -> EnhancementAnalysis: """Step 6: Kei 확인 후 보충 블록 선택 + fit 재검증. Kei가 승인한 제안에 대해: - fill_space → catalog에서 여유 공간에 맞는 블록 선택 - emphasis → 강조 문장 확정 - bold_keywords → 키워드 목록 확정 하드코딩 없음. 블록 선택은 catalog의 min_height_px로 판단. """ from src.block_reference import _load_catalog catalog = _load_catalog() for e in enhancements.enhancements: if e.type == "fill_space": surplus_px = e.detail.get("surplus_px", 0) has_table = e.detail.get("has_table", False) popup_title = e.detail.get("popup_title", "") # 여유 공간에 맞는 블록: catalog에서 min_height_px <= surplus_px target_categories = ["tables"] if has_table else ["cards", "emphasis"] candidates = [ b for b in catalog if b.get("category") in target_categories and b.get("min_height_px", 0) <= surplus_px ] if candidates: # 여유에 가장 가까운(크지만 넘지 않는) 블록 candidates.sort(key=lambda b: b.get("min_height_px", 0), reverse=True) selected = candidates[0] enhancements.supplement_blocks.append(SupplementBlock( role=e.role, block_id=selected["id"], variant="default", content_source=f"popup:{popup_title}", estimated_height_px=selected.get("min_height_px", 0), available_px=surplus_px, )) logger.info( f"[V-8] {e.role}: 보충 블록 {selected['id']} " f"(min_h={selected.get('min_height_px')}px, 여유={surplus_px}px)" ) elif e.type == "emphasis": conclusion = e.detail.get("conclusion_sentence", "") if conclusion: enhancements.emphasis_blocks.append({ "role": e.role, "topic_id": e.detail.get("topic_id"), "sentence": conclusion, }) elif e.type == "bold_keywords": role = e.role keywords = e.detail.get("keywords", []) if keywords: if role not in enhancements.bold_keywords: enhancements.bold_keywords[role] = [] enhancements.bold_keywords[role].extend(keywords) # fit 재검증: 보충 블록이 실제로 들어가는지 valid_supplements = [] for sb in enhancements.supplement_blocks: rf = analysis.roles.get(sb.role) if not rf: continue new_h = analysis.redistribution.get(sb.role, rf.allocated_px) if analysis.redistribution else rf.allocated_px remaining = new_h - rf.total_required_px if sb.estimated_height_px <= remaining: valid_supplements.append(sb) logger.info(f"[V-8] {sb.role}: 보충 {sb.block_id} 확정 ({sb.estimated_height_px}px <= 여유 {remaining}px)") else: logger.warning(f"[V-8] {sb.role}: 보충 {sb.block_id} 제외 ({sb.estimated_height_px}px > 여유 {remaining}px)") enhancements.supplement_blocks = valid_supplements return enhancements # ────────────────────────────────────── # Step 7: 세부 컨테이너 배치 계산 # ────────────────────────────────────── @dataclass class SubContainer: """세부 컨테이너 정보.""" name: str # "svg", "text", "table", "keymsg", "emphasis" 등 width_px: float height_px: float align: str = "stretch" # "stretch" | "center" @dataclass class ContainerLayout: """하나의 메인 컨테이너 안의 세부 배치.""" role: str main_height_px: float main_width_px: float sub_containers: list[SubContainer] = field(default_factory=list) table_rows: int = 0 # 보충 표 행 수 def calculate_sub_layout( role: str, main_height_px: float, main_width_px: float, topic_fits: list[TopicFit], enhancements: EnhancementAnalysis, font_hierarchy: dict[str, float], ) -> ContainerLayout: """메인 컨테이너 안에서 세부 컨테이너 배치를 계산. 이미지/텍스트/표/key-msg 등의 크기를 동적으로 결정. """ tokens = _load_design_tokens() layout = ContainerLayout(role=role, main_height_px=main_height_px, main_width_px=main_width_px) # 제목 높이: font_size * line_height + margin role_font_map = {"본심": "core", "배경": "bg", "첨부": "sidebar", "결론": "key_msg"} font_key = role_font_map.get(role, "core") title_font = font_hierarchy.get(font_key, 12) title_h = title_font * 1.5 + tokens["spacing_small"] # key-msg (본심에만) keymsg_h = 0 for tf in topic_fits: if tf.has_keymsg: keymsg_h = tf.keymsg_height_px break # 강조 블록 (배경 등) emphasis_h = 0 for eb in enhancements.emphasis_blocks: if eb.get("role") == role: emphasis_h = title_font * 1.4 + tokens["spacing_small"] * 2 break # 이미지 있는지 has_image = any(tf.has_image for tf in topic_fits) # 블록 padding overhead (선택된 블록의 padding/border 등) block_overhead = max((tf.block_overhead_px for tf in topic_fits), default=0) # 사용 가능 높이: 메인 - 제목 - key-msg - 강조 - 블록 padding - 간격 gap = tokens["spacing_small"] used_h = title_h + keymsg_h + emphasis_h + block_overhead used_h += gap * (2 if keymsg_h > 0 else 1) # 제목-콘텐츠 gap + 콘텐츠-keymsg gap content_h = max(0, main_height_px - used_h) if has_image: # SVG(좌) + 텍스트+표(우) 구조 # 이미지 최소 폭: catalog의 min_display_width_px from src.block_reference import _load_catalog img_min_w = tokens["spacing_page"] for b in _load_catalog(): if b.get("category") == "visuals" and b.get("min_display_width_px", 0) > img_min_w: img_min_w = b["min_display_width_px"] break # 첫 번째 visual 블록 기준 img_width = img_min_w text_width = main_width_px - img_width - gap # 텍스트 높이 (topic_fits에서) text_h = sum(tf.text_height_px for tf in topic_fits if not tf.has_keymsg) # 보충 표 사용 가능 높이 table_available = content_h - text_h - gap # 텍스트 아래 여유 table_rows = 0 for sb in enhancements.supplement_blocks: if sb.role == role: # 표 1행 높이: catalog의 padding_overhead_px / 3 (헤더+3행 기준) from src.block_reference import _load_catalog block = next((b for b in _load_catalog() if b["id"] == sb.block_id), None) if block: overhead = block.get("padding_overhead_px", 0) # 헤더 높이 ≈ overhead의 절반 header_h = overhead / 2 row_h = title_font * 1.5 + tokens["spacing_small"] # 1행 높이 # 가용 높이에서 헤더 빼고 행 수 계산 if table_available > header_h: table_rows = max(0, int((table_available - header_h) / row_h)) layout.sub_containers = [ SubContainer("svg", img_width, content_h, align="stretch"), SubContainer("text_and_table", text_width, content_h, align="center"), ] if keymsg_h > 0: layout.sub_containers.append(SubContainer("keymsg", main_width_px, keymsg_h)) layout.table_rows = table_rows else: # 이미지 없는 구조 (텍스트만) # 카드형: item_count > 1이면 카드당 높이 예산 계산 total_items = sum(tf.item_count for tf in topic_fits) if total_items > 1: card_gap = gap * (total_items - 1) per_card_h = max(0, (content_h - card_gap) / total_items) layout.sub_containers = [ SubContainer(f"card_{i+1}", main_width_px, per_card_h) for i in range(total_items) ] else: layout.sub_containers = [ SubContainer("text", main_width_px, content_h), ] if keymsg_h > 0: layout.sub_containers.append(SubContainer("keymsg", main_width_px, keymsg_h)) if emphasis_h > 0: layout.sub_containers.append(SubContainer("emphasis", main_width_px, emphasis_h)) logger.info( f"[V-Step7] {role}: main={main_height_px}px, " + ", ".join(f"{sc.name}={sc.width_px:.0f}×{sc.height_px:.0f}" for sc in layout.sub_containers) + (f", 표 {layout.table_rows}행" if layout.table_rows > 0 else "") ) return layout