- block_assembler: _assemble_slide_html_type_b 추가 (filled/after용 HTML 생성) - fit_verifier: redistribute()가 ROLE_ZONE_MAP 대신 containers zone 사용 - renderer: render_slide_from_html()에 zone 기반 높이 탐색 추가 - pipeline: 팝업 HTML CSS를 콘텐츠 유형별(table/list/text) 분기 - run_from_stage1b: MDX 파일 하드코딩 제거 + layout_template 전달 추가 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1049 lines
42 KiB
Python
1049 lines
42 KiB
Python
"""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
|