Files
C.E.L_Slide_test2/src/fit_verifier.py
kyeongmin d4eaec694c 유형 B 파이프라인 연결: block_assembler type B 조립 + zone 기반 전환 시작
- 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>
2026-04-07 04:39:02 +09:00

1049 lines
42 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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