Add Type B slide pipeline and recipe rendering updates

This commit is contained in:
2026-04-15 16:39:50 +09:00
parent 51548fdc41
commit 66c00924ed
22 changed files with 6260 additions and 1322 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -1,277 +1,457 @@
"""유형 B'' 조립 함수 — 참고 이미지 스타일 (border 없음, 색상바+여백으로 구분)."""
"""유형 B'' 조립 함수 — slide-base.html + 블록 템플릿 사용.
변경 이력:
- 기존: f-string 하드코딩 HTML
- 현재: slide-base.html 래핑 + templates/blocks/ 블록 Jinja2 렌더링 + font_hierarchy 적용
원칙:
- 블록 CSS의 글씨 크기를 font_hierarchy에 맞게 조정 (프로세스 내 조정)
- 콘텐츠는 PipelineContext에서 가져옴 (하드코딩 아님)
- 블록은 콘텐츠에 맞게 재구성 (items 수 동적)
"""
from __future__ import annotations
import base64
import re
from pathlib import Path
from typing import TYPE_CHECKING
from jinja2 import Environment, FileSystemLoader
if TYPE_CHECKING:
from src.pipeline_context import PipelineContext
BLOCKS_DIR = Path("templates/blocks")
SVG_DIR = BLOCKS_DIR / "svg"
_env = Environment(loader=FileSystemLoader(str(BLOCKS_DIR)), autoescape=False)
def _img_b64(filename: str) -> str:
"""SVG/PNG → data URI."""
p = SVG_DIR / filename
if not p.exists():
return ""
ext = "svg+xml" if filename.endswith(".svg") else "png"
return f"data:image/{ext};base64," + base64.b64encode(p.read_bytes()).decode()
def _strip_comments(html: str) -> str:
return re.sub(r'<!--.*?-->', '', html, flags=re.DOTALL).strip()
def _render_slide_base(title: str, body_html: str, footer_text: str) -> str:
"""slide-base.html로 래핑. 공통 함수."""
sb = _strip_comments((BLOCKS_DIR / "slide-base.html").read_text(encoding="utf-8"))
r = sb.replace('{{ title|default("슬라이드") }}', title)
r = r.replace('{{ title|default("슬라이드 제목") }}', title)
r = r.replace('{% block body %}{% endblock %}', body_html)
pill = _img_b64("pill_scroll.png")
r = r.replace('{% if footer_text %}', '').replace('{% if footer_pill_bg %}', '')
r = r.replace('{{ footer_pill_bg }}', pill).replace('{% else %}', '')
r = r.replace('<div class="slide-footer-bg slide-footer--css"></div>', '')
li = r.rfind('{% endif %}')
if li > 0:
r = r[:li] + r[li + len('{% endif %}'):]
r = r.replace('{% endif %}', '').replace('{{ footer_text|safe }}', footer_text)
r = r.replace('src="svg/bg_slide_texture.png"', f'src="{_img_b64("bg_slide_texture.png")}"')
r = r.replace('src="svg/line_divider.svg"', f'src="{_img_b64("line_divider.svg")}"')
return r
def _assemble_slide_html_type_b_double_prime(ctx: "PipelineContext", title_text: str = "") -> str:
"""유형 B'' - 참고 이미지 스타일.
"""유형 B'' — slide-base.html + 블록 템플릿 + font_hierarchy.
border/gradient 박스 없음. 색상 바 + 폰트 크기 + 여백으로 구분.
블록 선택: PipelineContext.references에서 가져옴.
콘텐츠: PipelineContext.normalized.sections + structured_text에서 가져옴.
글씨 크기: font_hierarchy(core/bg/sidebar/key_msg)에서 가져옴.
"""
from src.fit_verifier import _load_design_tokens
tokens = _load_design_tokens()
pad = tokens["spacing_page"]
header_h = tokens.get("header_height", 66)
gap_block = tokens["spacing_block"]
gap_small = tokens["spacing_small"]
slide_w = tokens.get("slide_width", 1280)
slide_h = tokens.get("slide_height", 720)
inner_w = slide_w - pad * 2
ps = ctx.page_structure.roles
enh = ctx.enhancement_result or {}
bold_kw = enh.get("bold_keywords", {}) if isinstance(enh.get("bold_keywords"), dict) else {}
font_h = ctx.font_hierarchy
font_size = font_h.core
title = title_text or ctx.analysis.title or ""
core_message = ctx.analysis.core_message or ""
ps = ctx.page_structure.roles
norm_sections = ctx.normalized.sections or []
norm_tables = ctx.normalized.tables or []
enh = ctx.enhancement_result or {}
bold_kw = enh.get("bold_keywords", {}) if isinstance(enh.get("bold_keywords"), dict) else {}
kei_decisions = enh.get("kei_decisions", [])
popup_roles = set()
for d in kei_decisions:
if d.get("action") == "popup":
popup_roles.add(d.get("role", ""))
top_role = bottom_left_role = bottom_right_role = footer_role = None
# zone 분류
zones = {}
for role_name, info in ps.items():
if not isinstance(info, dict):
continue
zone = info.get("zone", "")
if zone == "top":
top_role = (role_name, info)
elif zone == "bottom_left":
bottom_left_role = (role_name, info)
elif zone == "bottom_right":
bottom_right_role = (role_name, info)
elif zone == "footer":
footer_role = (role_name, info)
if isinstance(info, dict):
zones[info.get("zone", "")] = (role_name, info)
footer_ci = ctx.containers.get(footer_role[0]) if footer_role else None
footer_h_px = footer_ci.height_px if footer_ci else 53
ft_top = slide_h - pad - footer_h_px
top_ci = ctx.containers.get(top_role[0]) if top_role else None
top_h = top_ci.height_px if top_ci else 200
top_top = pad + header_h + gap_block
bottom_top = top_top + top_h + gap_small
bottom_h = ft_top - gap_block - bottom_top
top_role = zones.get("top")
bl_role = zones.get("bottom_left")
br_role = zones.get("bottom_right")
footer_role = zones.get("footer")
def _bold(text, role):
def _bold(text, role=""):
for kw in bold_kw.get(role, []):
if kw in text:
text = text.replace(kw, f"<strong>{kw}</strong>")
return text
# 색상 (참고 이미지 기반)
bar_colors = ["#2d5016", "#5c3d1a", "#1a365d"]
accent = "#c05621"
# ── 상단: 블록 레퍼런스에서 block_id 확인 → 블록 템플릿 렌더링 ──
top_html = _render_top_zone(ctx, norm_sections, font_h, _bold)
# ── 상단 ──
top_html = ""
if top_role:
rn = top_role[0]
topic_title = ""
top_secs = [] # [(title, [(depth, text)])]
cur_title = ""
cur_items = []
for s in norm_sections:
if s.get("level") == 3:
break
if not topic_title and s.get("title"):
topic_title = s["title"]
content = s.get("content", "")
if not content:
continue
st = s.get("title", "")
if st and st != topic_title:
if cur_title:
top_secs.append((cur_title, cur_items))
cur_title = st
cur_items = []
for line in content.split("\n"):
stripped = line.strip()
if not stripped:
continue
if re.search(r'\[팝업:', stripped):
continue
if re.search(r'\[이미지:', stripped) or re.match(r'^!\[', stripped):
continue
if re.search(r'\[핵심요약:', stripped):
# ── 하단: process-product-2col 또는 블록 레퍼런스 기반 ──
bottom_html = _render_bottom_zone(ctx, norm_sections, norm_tables, font_h, _bold)
# ── font_hierarchy CSS override ──
font_css = f"""<style>
/* font_hierarchy: key_msg={font_h.key_msg}px, core={font_h.core}px, bg={font_h.bg}px, sidebar={font_h.sidebar}px */
.p3c-heading {{ font-size: {font_h.core}px !important; line-height: 1.5 !important; }}
.p3c-desc {{ font-size: {font_h.sidebar}px !important; line-height: 1.6 !important; }}
.p3c-desc .bul {{ padding-left: 12px; text-indent: -12px; }}
.p3c-vlabel {{ font-size: {font_h.key_msg}px !important; }}
.p3c-vlabel-sub {{ font-size: {font_h.core}px !important; }}
.p3c-kanji {{ display: none !important; }}
.p3c-vlabel-area {{ width: 56px !important; }}
.p3c-section {{ left: 60px !important; right: 6px !important; }}
.p3c-mid-line {{ left: 56px !important; }}
.p3c-col {{ min-height: 0 !important; height: 100% !important; }}
.block-p3c {{ height: 100% !important; }}
.pp2-header-text {{ font-size: {font_h.core + 1}px !important; font-weight: 900 !important; }}
.pp2-header-text--right {{ color: #ffffff !important; }}
.pp2-mid-title {{ font-size: {font_h.core}px !important; line-height: 1.5 !important; margin-top: 4px !important; }}
.pp2-mid-title:first-child {{ margin-top: 0 !important; }}
.pp2-body-text {{ font-size: {font_h.sidebar}px !important; line-height: 1.6 !important; padding-left: 12px !important; text-indent: -12px !important; font-weight: 500 !important; }}
</style>"""
# ── zone 제목 추출 ──
# 상단: 첫 번째 level=2 (콘텐츠 없는 대제목)
# 하단: level=3 직전의 level=2 (하단 대제목)
top_zone_title = ""
bottom_zone_title = ""
for i, s in enumerate(norm_sections):
if s.get("level") == 2:
if not s.get("content", "").strip():
# 콘텐츠 없는 level=2 = zone 제목
# 다음 section이 level=3이면 하단 제목
if i + 1 < len(norm_sections) and norm_sections[i + 1].get("level") == 3:
bottom_zone_title = s.get("title", "")
elif not top_zone_title:
top_zone_title = s.get("title", "")
# ── 조립 ──
body = f"""{font_css}
<div style="height:38%;margin-bottom:1%;padding-top:8px;">
<div style="font-weight:700;font-size:{font_h.core + 1}px;color:#1a365d;margin-bottom:8px;">
{top_zone_title}
</div>
<div style="height:calc(100% - 28px);padding:0 12px 0 24px;">{top_html}</div>
</div>
<div style="height:60%;margin-top:12px;">
<div style="font-weight:700;font-size:{font_h.core + 1}px;color:#1a365d;margin-bottom:8px;">
{bottom_zone_title}
</div>
<div style="height:calc(100% - 28px);padding:0 12px 0 24px;">{bottom_html}</div>
</div>"""
footer_text_html = f'{core_message}'.replace(
'기대할 수 있다', '<em>기대할 수 있다</em>'
) if core_message else ""
return _render_slide_base(title, body, footer_text_html)
def _get_zone_title(sections, level=2, index=0):
"""normalized.sections에서 level=N인 제목을 index번째 가져옴."""
count = 0
for s in sections:
if s.get("level") == level:
if count == index:
return s.get("title", "")
count += 1
return ""
def _render_top_zone(ctx, sections, font_h, bold_fn):
"""상단 zone 렌더링 — normalized sections의 level=2 카테고리를 직접 사용."""
# 상단 topic_ids에 해당하는 sections 가져오기
ps = ctx.page_structure.roles
top_zone = None
for role_name, info in ps.items():
if isinstance(info, dict) and info.get("zone") == "top":
top_zone = (role_name, info)
break
if not top_zone:
return "<div>상단 zone 없음</div>"
top_topic_ids = top_zone[1].get("topic_ids", [])
topic_map = {t.id: t for t in ctx.topics}
# 각 topic의 structured_text 또는 normalized section에서 콘텐츠 가져오기
categories = []
for tid in top_topic_ids:
topic = topic_map.get(tid)
if not topic:
continue
cat_name = topic.title or ""
# structured_text 우선, 없으면 normalized sections에서 찾기
content = topic.structured_text or ""
if not content:
for s in sections:
if s.get("title") == cat_name and s.get("content"):
content = s["content"]
break
depth = 0
dm = re.match(r'^D(\d+):\s*', stripped)
if dm:
depth = int(dm.group(1))
stripped = re.sub(r'^D\d+:\s*', '', stripped)
stripped = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', stripped)
cur_items.append((depth, stripped))
if cur_title:
top_secs.append((cur_title, cur_items))
cards = ""
for ci_idx, (st, items) in enumerate(top_secs):
bc = bar_colors[ci_idx % len(bar_colors)]
card = f'<div style="flex:1;">'
card += (
f'<div style="background:{bc};color:#fff;font-weight:700;'
f'font-size:{font_size}px;padding:4px 10px;margin-bottom:4px;">'
f'{_bold(st, rn)}</div>'
)
for depth, text in items:
text = _bold(text, rn)
if depth <= 1 and '<strong>' in text:
card += (
f'<div style="padding-left:{int(font_size * 0.8)}px;margin-bottom:2px;'
f'font-size:{font_size - 1}px;color:{accent};font-weight:600;">'
f'{text}</div>'
)
if not content:
continue
# D1/D2 마커 기반 파싱
headings = []
current_heading = None
for line in content.split("\n"):
stripped = line.strip()
if not stripped:
continue
stripped = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', stripped)
dm = re.match(r'^D(\d+):\s*', stripped)
depth = int(dm.group(1)) if dm else 0
if dm:
stripped = re.sub(r'^D\d+:\s*', '', stripped)
clean = stripped.lstrip("•- ").strip()
if not clean:
continue
if depth <= 1 and '<strong>' in clean:
current_heading = {"title": clean, "bullets": []}
headings.append(current_heading)
else:
if current_heading:
current_heading["bullets"].append(clean)
elif headings:
headings[-1]["bullets"].append(clean)
else:
card += (
f'<div style="padding-left:{int(font_size * 1.5)}px;margin-bottom:1px;'
f'font-size:{font_size - 2}px;color:#333;">'
f'\u2022 {text}</div>'
)
card += '</div>'
cards += card
headings.append({"title": "", "bullets": [clean]})
top_html = (
f'<div style="height:100%;padding:{gap_small}px;">'
f'<div style="font-weight:700;font-size:{font_size + 2}px;color:#1a365d;margin-bottom:6px;">'
f'{_bold(topic_title or rn, rn)}</div>'
f'<div style="display:flex;gap:{gap_small}px;flex:1;">{cards}</div></div>'
)
categories.append({"name": cat_name, "headings": headings})
import logging
logging.getLogger(__name__).info(f"[B'' top] cat={cat_name}, headings={len(headings)}")
# ── 하단 ──
bottom_title = ""
if not categories:
return "<div>콘텐츠 없음</div>"
# 블록 CSS 가져오기
p3c_raw = (BLOCKS_DIR / "new" / "prerequisites-3col.html").read_text(encoding="utf-8")
p3c_css = re.search(r'<style>(.*?)</style>', p3c_raw, re.DOTALL)
css_html = p3c_css.group(0) if p3c_css else ""
# 동적 열 생성
bar_gradients = [
"linear-gradient(180deg, #0D78D0 0%, #023056 100%)",
"linear-gradient(180deg, #FF9A23 0%, #CC5200 100%)",
"linear-gradient(180deg, #39BE49 0%, #23742C 100%)",
"linear-gradient(180deg, #7c3aed 0%, #4c1d95 100%)",
]
heading_gradients = [
"linear-gradient(180deg, #0D78D0 0%, #134D7F 100%)",
"linear-gradient(180deg, #CC5200 0%, #883700 100%)",
"linear-gradient(180deg, #39BE49 0%, #1E6328 100%)",
"linear-gradient(180deg, #7c3aed 0%, #5b21b6 100%)",
]
cols_html = ""
for ci, cat in enumerate(categories):
# 카테고리명에서 "기술(디지털)" → name="기술", sub="디지털"
name_match = re.match(r'^(.+?)[(](.+?)[)]$', cat["name"])
if name_match:
name, sub = name_match.group(1), name_match.group(2)
else:
name, sub = cat["name"], ""
bar = bar_gradients[ci % len(bar_gradients)]
hgrad = heading_gradients[ci % len(heading_gradients)]
# 항목 HTML — 동적 items 수
items = cat["headings"]
n = max(len(items), 1)
items_html = ""
for i, item in enumerate(items):
if not item["title"] and not item["bullets"]:
continue
pct_h = int(95 / n)
pct_top = int(3 + i * (95 / n))
bul = "".join(f'<div class="bul">• {b}</div>' for b in item["bullets"])
items_html += f"""
<div class="p3c-section" style="position:absolute;left:60px;right:6px;top:{pct_top}%;height:{pct_h}%;">
<div class="p3c-heading" style="background-image:{hgrad}">{item['title']}</div>
<div class="p3c-desc">{bul}</div>
</div>"""
if i < n - 1 and len(items) > 1:
line_top = pct_top + pct_h
items_html += f'<div class="p3c-mid-line" style="position:absolute;left:56px;right:0;top:{line_top}%;border-top:1.2px dashed #000;"></div>'
cols_html += f"""
<div class="p3c-col" style="flex:1;position:relative;height:100%;border-top:1.2px solid #000;border-bottom:1.2px solid #000;">
<div class="p3c-bar" style="background:{bar};position:absolute;left:0;top:0;width:56px;height:100%;"></div>
<div class="p3c-vlabel-area" style="position:absolute;left:0;top:0;width:56px;height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:4px;z-index:3;">
<div class="p3c-vlabel">{name}</div>
{'<div class="p3c-vlabel-sub">' + sub + '</div>' if sub else ''}
</div>
{items_html}
</div>"""
return f'<div class="block-p3c" style="display:flex;gap:12px;width:100%;height:100%;">{cols_html}</div>\n{css_html}'
def _render_bottom_zone(ctx, sections, tables, font_h, bold_fn):
"""하단 zone 렌더링 — 좌우 2분할, 소제목 행 정렬."""
# 하단 콘텐츠: level=3인 sections
sub_secs = []
for s in norm_sections:
for s in sections:
if s.get("level") == 3:
sub_secs.append((s.get("title", ""), s.get("content", "")))
for s in norm_sections:
if s.get("level") == 2:
idx = norm_sections.index(s)
if idx + 1 < len(norm_sections) and norm_sections[idx + 1].get("level") == 3:
bottom_title = s.get("title", "")
break
norm_tables = ctx.normalized.tables or []
if not sub_secs:
return "<div>하단 콘텐츠 없음</div>"
# 좌/우 분리 (첫 번째 sub_sec가 좌, 두 번째가 우)
left_title = sub_secs[0][0] if sub_secs else ""
right_title = sub_secs[1][0] if len(sub_secs) > 1 else ""
# 좌측 소제목+불릿 파싱
left_items = _parse_sub_content(sub_secs[0][1] if sub_secs else "", tables, bold_fn)
right_items = _parse_sub_content(sub_secs[1][1] if len(sub_secs) > 1 else "", [], bold_fn)
# 좌우 소제목 행 매칭
max_rows = max(len(left_items), len(right_items))
while len(left_items) < max_rows:
left_items.append(("", []))
while len(right_items) < max_rows:
right_items.append(("", []))
# 블록 CSS
pp2_raw = (BLOCKS_DIR / "BEPs" / "process-product-2col.html").read_text(encoding="utf-8")
pp2_css = re.search(r'<style>(.*?)</style>', pp2_raw, re.DOTALL)
css_html = pp2_css.group(0) if pp2_css else ""
arrow_uri = _img_b64("arrow_asis_tobe.png")
# Grid 생성 — 행 높이 동기화 + 전체 열 gradient
rows_html = ""
for i, ((lt, lbullets), (rt, rbullets)) in enumerate(zip(left_items, right_items)):
pad = "3px 16px" if i == 0 else "2px 16px"
# 좌측
left_cell = f'<div style="padding:{pad};">'
if lt:
left_cell += f'<div class="pp2-mid-title pp2-mid-title--left">{lt}</div>'
# 테이블 (As-is → To-be) 이 있으면 첫 번째 행에 삽입
if i == 0 and tables:
left_cell += _render_compare_table(tables[0], arrow_uri, font_h)
for b in lbullets:
left_cell += f'<div class="pp2-body-text">• {b}</div>'
left_cell += '</div>'
# 우측
right_cell = f'<div style="padding:{pad};">'
if rt:
right_cell += f'<div class="pp2-mid-title pp2-mid-title--right">{rt}</div>'
for b in rbullets:
right_cell += f'<div class="pp2-body-text">• {b}</div>'
right_cell += '</div>'
rows_html += left_cell + right_cell
# 헤더
header_html = f"""
<div class="pp2-header-bar pp2-header-bar--left" style="background:linear-gradient(270deg,#a4a096 0%,#39311e 100%);border-radius:0 24px 24px 0;display:flex;align-items:center;justify-content:center;height:30px;margin-top:4px;">
<span class="pp2-header-text pp2-header-text--left" style="color:#3e3523;">{left_title}</span>
</div>
<div class="pp2-header-bar pp2-header-bar--right" style="background:linear-gradient(90deg,#296b55 0%,#022017 100%);border-radius:24px 0 0 24px;display:flex;align-items:center;padding-left:20px;height:30px;margin-top:4px;">
<span class="pp2-header-text pp2-header-text--right">{right_title}</span>
</div>"""
return f"""
<div style="position:relative;width:100%;height:100%;">
<div style="position:absolute;left:0;top:0;width:50%;height:100%;background:linear-gradient(180deg,#ffffff 46%,#39311e 100%);z-index:0;"></div>
<div style="position:absolute;left:50%;top:0;width:50%;height:100%;background:linear-gradient(0deg,#296b55 0%,#ffffff 56%);z-index:0;"></div>
<div style="position:relative;z-index:1;display:grid;grid-template-columns:1fr 1fr;width:100%;height:100%;">
{header_html}
{rows_html}
</div>
</div>
{css_html}"""
def _parse_sub_content(content, tables, bold_fn):
"""하위 콘텐츠를 소제목+불릿 리스트로 파싱."""
content = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', content)
items = []
current_title = ""
current_bullets = []
# 테이블 텍스트 (중복 제거용)
table_texts = set()
for td in norm_tables:
for td in tables:
for h in td.get("headers", []):
table_texts.add(h.strip().lstrip("*").rstrip("*"))
for row in td.get("rows", []):
for c in row:
table_texts.add(str(c).strip().lstrip("*").rstrip("*"))
def _render_section(sub_title, sub_content, rn, bar_color, include_table=False):
sub_content = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', sub_content)
html = (
f'<div style="height:100%;padding:{gap_small}px;">'
f'<div style="background:{bar_color};color:#fff;font-weight:700;font-size:{font_size}px;'
f'padding:4px 10px;margin-bottom:6px;">{_bold(sub_title, rn)}</div>'
)
# 표
if include_table and norm_tables:
for td in norm_tables:
headers = td.get("headers", [])
rows = td.get("rows", [])
if headers and rows:
col_count = len(headers)
h_cells = "".join(
f'<th style="padding:3px 6px;font-size:{font_size - 2}px;background:{bar_color};'
f'color:#fff;border:1px solid #ccc;text-align:center;">{h}</th>'
for h in headers
)
r_html = ""
for ri, row in enumerate(rows):
bg = "#f5f5f0" if ri % 2 == 0 else "#fff"
cells = "".join(
f'<td style="padding:3px 6px;font-size:{font_size - 2}px;border:1px solid #ddd;background:{bg};">'
f'{re.sub(r"\\*\\*(.+?)\\*\\*", r"<strong>\\1</strong>", str(c))}</td>'
for c in row
)
r_html += f'<tr>{cells}</tr>'
html += f'<table style="border-collapse:collapse;width:100%;margin-bottom:6px;"><tr>{h_cells}</tr>{r_html}</table>'
# 불릿
for line in sub_content.split("\n"):
stripped = line.strip()
if not stripped:
continue
depth = 1
dm = re.match(r'^D(\d+):\s*', stripped)
if dm:
depth = int(dm.group(1))
stripped = re.sub(r'^D\d+:\s*', '', stripped)
clean = stripped.lstrip("- ").lstrip("\u2022 ")
clean_plain = re.sub(r'<[^>]+>', '', clean).strip()
if clean_plain in table_texts or clean_plain == "\u27a0":
continue
if re.search(r'\[핵심요약:', clean):
break
if not clean:
continue
clean = _bold(clean, rn)
if depth == 1 and '<strong>' in clean:
html += (
f'<div style="margin-bottom:2px;font-size:{font_size - 1}px;'
f'color:{accent};font-weight:600;">{clean}</div>'
)
else:
html += (
f'<div style="padding-left:{int(font_size * 1.2)}px;margin-bottom:1px;'
f'font-size:{font_size - 2}px;color:#333;">\u2022 {clean}</div>'
)
html += '</div>'
return html
for line in content.split("\n"):
stripped = line.strip()
if not stripped:
continue
bl_html = ""
if sub_secs and bottom_left_role:
rn = bottom_left_role[0]
bl_html = _render_section(sub_secs[0][0], sub_secs[0][1], rn, bar_colors[0], include_table=True)
# D마커
dm = re.match(r'^D(\d+):\s*', stripped)
if dm:
stripped = re.sub(r'^D\d+:\s*', '', stripped)
br_html = ""
if bottom_right_role and len(sub_secs) > 1:
rn = bottom_right_role[0]
br_html = _render_section(sub_secs[1][0], sub_secs[1][1], rn, bar_colors[1], include_table=False)
clean = stripped.lstrip("•- ").strip()
clean_plain = re.sub(r'<[^>]+>', '', clean).strip()
# 결론
footer_html = ""
if footer_role:
rn = footer_role[0]
footer_html = (
f'<div style="background:linear-gradient(135deg,#006aff 0%,#00aaff 100%);'
f'border-radius:8px;padding:{int(font_size * 1.2)}px;text-align:center;color:#fff;height:100%;'
f'display:flex;align-items:center;justify-content:center;">'
f'<div style="font-size:{font_h.key_msg}px;font-weight:700;">{_bold(core_message, rn)}</div></div>'
)
if clean_plain in table_texts or clean_plain == "":
continue
if re.search(r'\[핵심요약:', clean):
break
if not clean:
continue
return f"""<!DOCTYPE html><html><head><meta charset="UTF-8">
<style>
*{{margin:0;padding:0;box-sizing:border-box;}}
body{{background:#e5e5e5;padding:10px;font-family:'Pretendard Variable','Noto Sans KR',sans-serif;word-break:keep-all;}}
</style></head><body>
<div class="slide" style="width:{slide_w}px;height:{slide_h}px;background:white;position:relative;border:1px solid #ccc;">
# 소제목 감지 (볼드)
if '<strong>' in clean and len(clean) < 80:
if current_title or current_bullets:
items.append((current_title, current_bullets))
current_title = clean
current_bullets = []
else:
current_bullets.append(clean)
<div style="position:absolute;left:{pad}px;top:{pad}px;width:{inner_w}px;height:{header_h}px;background:#f8fafc;border-bottom:3px solid #2563eb;display:flex;align-items:center;padding:0 20px;font-size:{tokens.get('font_title', 22)}px;font-weight:900;color:#1e293b;">{title}</div>
if current_title or current_bullets:
items.append((current_title, current_bullets))
<div class="area-top" style="position:absolute;left:{pad}px;top:{top_top}px;width:{inner_w}px;height:{top_h}px;overflow:hidden;">
{top_html}</div>
return items
<div class="area-bottom" style="position:absolute;left:{pad}px;top:{bottom_top}px;width:{inner_w}px;height:{bottom_h}px;overflow:hidden;">
<div style="font-weight:700;font-size:{font_size+1}px;color:#1a365d;padding-bottom:4px;border-bottom:2px solid #1a365d;margin-bottom:4px;">{_bold(bottom_title, "")}</div>
<div style="display:flex;height:calc(100% - {int(font_size * 1.5 + 10)}px);">
<div class="area-bottom-left" style="flex:1;overflow:hidden;">
{bl_html}</div>
<div style="width:1px;background:#cbd5e1;flex-shrink:0;margin:0 {gap_small}px;"></div>
<div class="area-bottom-right" style="flex:1;overflow:hidden;">
{br_html}</div>
</div></div>
<div class="area-footer" style="position:absolute;left:{pad}px;top:{ft_top}px;width:{inner_w}px;height:{footer_h_px}px;border-radius:8px;overflow:hidden;">
{footer_html}</div>
def _render_compare_table(table_data, arrow_uri, font_h):
"""As-is → To-be 비교 테이블 렌더링."""
headers = table_data.get("headers", [])
rows = table_data.get("rows", [])
if not headers or not rows:
return ""
</div></body></html>"""
def _clean_md(text):
"""**볼드** 마크다운 제거 — 테이블 셀은 일반 텍스트."""
return re.sub(r'\*\*(.+?)\*\*', r'\1', str(text))
html = '<div style="display:flex;align-items:center;gap:4px;margin-bottom:4px;">'
html += '<div style="flex:1;">'
for row in rows:
html += f'<div class="pp2-body-text">• {_clean_md(row[0])}</div>'
html += '</div>'
html += f'<div style="flex-shrink:0;width:30px;text-align:center;"><img src="{arrow_uri}" style="width:30px;height:16px;object-fit:contain;" alt=""></div>'
html += '<div style="flex:1;">'
for row in rows:
val = row[2] if len(row) > 2 else ""
html += f'<div class="pp2-body-text">• {_clean_md(val)}</div>'
html += '</div></div>'
return html

View File

@@ -149,25 +149,128 @@ def _match_visual_type(expression_hint: str) -> tuple[str, list[str]]:
DARK_BLOCKS = {"dark-bullet-list", "card-dark-overlay"}
def _match_by_tags(
catalog: list[dict],
topic_count: int,
topic_titles: list[str],
container_height_px: int,
zone: str,
) -> dict | None:
"""catalog의 tags 필드로 콘텐츠 패턴 매칭.
매칭 기준 (AND 조건):
1. item_count가 topic_count와 일치 (필수)
2. content_example에 topic 제목 키워드 포함 (가산)
3. container 크기 적합 (감점)
threshold: item_count 매칭(필수) + content_example 매칭 1개 이상
"""
if topic_count <= 0:
return None
scored = []
for block in catalog:
tags = block.get("tags", {})
if not tags:
continue
item_count_matched = False
content_matched = 0
# item_count 매칭
tag_item_count = tags.get("item_count")
if tag_item_count:
try:
if int(tag_item_count) == topic_count:
item_count_matched = True
except (ValueError, TypeError):
parts = str(tag_item_count).split("-")
if len(parts) == 2:
try:
lo, hi = int(parts[0]), int(parts[1])
if lo <= topic_count <= hi:
item_count_matched = True
except (ValueError, TypeError):
pass
if not item_count_matched:
continue # item_count 안 맞으면 스킵
# content_example에 topic 제목 키워드 포함되는지
score = 50 # item_count 매칭 기본점
content_example = tags.get("content_example", "").lower()
if content_example:
for t in topic_titles:
key = t.split("(")[0].strip().lower()
if key and len(key) >= 2 and key in content_example:
content_matched += 1
score += 20
# source_mdx 매칭
if tags.get("source_mdx"):
score += 3
# Y-11c: shape 특성 가산점
# redesign 블록 (특화) > 범용 블록
if block.get("category") == "redesign":
score += 5
# 비교 표가 있는 블록은 비대칭 구조에서 우선
if tags.get("has_compare_table"):
score += 5
# threshold: item_count 매칭 + content_example 1개 이상 매칭
if content_matched >= 1:
scored.append((score, block))
logger.info(
f"[T-3 tag] {block['id']}: score={score} "
f"(content_matched={content_matched}/{len(topic_titles)})"
)
if not scored:
return None
scored.sort(key=lambda x: -x[0])
return scored[0][1]
def select_reference_block(
relation_type: str,
expression_hint: str,
container_height_px: int,
zone: str = "body",
role: str = "",
topic_count: int = 0,
topic_titles: list[str] | None = None,
) -> dict[str, Any]:
"""참고 블록 선택 (2단계 필터 + 역할 제약 + 컨테이너 적합성 + fallback).
"""참고 블록 선택 (tag 매칭 → relation_type → fallback).
1순위: catalog tags의 content_pattern/item_count로 정확 매칭
2순위: relation_type → 카테고리 필터
3순위: fallback
Returns:
{
"block_id": str,
"variant": str,
"visual_type": str,
"catalog_entry": dict, # catalog.yaml의 해당 블록 전체
"catalog_entry": dict,
}
"""
catalog = _load_catalog()
# ══ 0순위: tag 기반 정확 매칭 ══
tag_match = _match_by_tags(catalog, topic_count, topic_titles or [], container_height_px, zone)
if tag_match:
logger.info(f"[T-3] tag 매칭 성공: {tag_match['id']} (content_pattern={tag_match.get('tags',{}).get('content_pattern','')})")
variant = "default"
return {
"block_id": tag_match["id"],
"variant": variant,
"visual_type": "tag_match",
"catalog_entry": tag_match,
}
# ── 1차 필터: relation_type → 카테고리 ──
allowed_categories = RELATION_CATEGORY_MAP.get(relation_type, ["emphasis"])
candidates_1 = [
@@ -462,6 +565,12 @@ def select_and_generate_references(
_tokens = _load_design_tokens()
gap_between = _tokens["spacing_small"]
# _plus_visual schema는 주종 관계 무시 → recipe executor가 처리
role_info_for_schema = page_structure.get(role, {})
role_schema = role_info_for_schema.get("group_schema", "") if isinstance(role_info_for_schema, dict) else ""
if "_plus_visual" in role_schema:
is_hierarchical = False # recipe로 보냄
if is_hierarchical:
# 주종 관계: 주 꼭지(intro/core) 기준으로 블록 1개 선택
# 종속 꼭지(supporting)는 블록 안에 하위 요소로 포함
@@ -477,12 +586,17 @@ def select_and_generate_references(
relation_type = primary_topic.get("relation_type", "none")
expression_hint = primary_topic.get("expression_hint", "")
# tag 매칭용: 이 role에 속한 모든 topic 제목
all_topic_titles = [topic_map.get(tid, {}).get("title", "") for tid in topic_ids]
selection = select_reference_block(
relation_type=relation_type,
expression_hint=expression_hint,
container_height_px=total_height_px,
zone=zone,
role=role,
topic_count=len(topic_ids),
topic_titles=all_topic_titles,
)
ref_html = generate_design_reference(
block_id=selection["block_id"],
@@ -507,50 +621,134 @@ def select_and_generate_references(
f"주={primary_tid}, 종={supporting_tids}"
)
else:
# 동급: 꼭지별 블록 선택
topic_count = len(topic_ids)
available_for_topics = total_height_px - gap_between * max(0, topic_count - 1)
min_block_height = min(
(b.get("min_height_px", 0) for b in _load_catalog() if b.get("min_height_px", 0) > 0),
default=1,
)
per_topic_height = max(min_block_height, available_for_topics // topic_count)
role_refs = []
for tid in topic_ids:
topic = topic_map.get(tid, {})
relation_type = topic.get("relation_type", "none")
expression_hint = topic.get("expression_hint", "")
selection = select_reference_block(
relation_type=relation_type,
expression_hint=expression_hint,
container_height_px=per_topic_height,
zone=zone,
role=role,
)
ref_html = generate_design_reference(
block_id=selection["block_id"],
variant=selection["variant"],
catalog_entry=selection["catalog_entry"],
)
schema_info = selection["catalog_entry"].get("schema", {})
role_refs.append({
"block_id": selection["block_id"],
"variant": selection["variant"],
"visual_type": selection["visual_type"],
"schema_info": schema_info,
"design_reference_html": ref_html,
"topic_id": tid,
})
# Phase Y: sub_titles 기반 블록 매칭 (Kei topic 수에 의존 안 함)
role_refs = [] # 초기화
role_info = page_structure.get(role, {})
sub_titles = role_info.get("sub_titles", []) if isinstance(role_info, dict) else []
slot_count = len(sub_titles) if sub_titles else len(topic_ids)
slot_titles = sub_titles if sub_titles else [topic_map.get(tid, {}).get("title", "") for tid in topic_ids]
# _plus_visual schema는 direct block 선택 금지 → recipe executor가 처리
group_schema = role_info.get("group_schema", "") if isinstance(role_info, dict) else ""
if "_plus_visual" in group_schema:
from src.section_parser import get_recipe_for_schema
recipe = get_recipe_for_schema(group_schema)
recipe_type = recipe.get("recipe", "") if recipe else ""
role_refs = [{
"block_id": "__needs_recipe__",
"variant": "default",
"visual_type": "recipe",
"schema_info": {"recipe": recipe_type, "group_schema": group_schema},
"design_reference_html": "",
"topic_id": topic_ids[0],
"supporting_topic_ids": topic_ids[1:],
"is_hierarchical": True,
}]
logger.info(
f"[V-1] {role}/꼭지{tid}: {selection['block_id']} "
f"(visual_type={selection['visual_type']}, variant={selection['variant']}, "
f"budget={per_topic_height}px)"
f"[V-1] {role}: _plus_visual → recipe '{recipe_type}' "
f"(direct block 선택 건너뜀)"
)
else:
# Y-14: tag_match와 schema_match 동등 비교
zone_tag_match = _match_by_tags(
_load_catalog(), slot_count, slot_titles,
total_height_px, zone,
)
# schema_match
zone_schema_match = None
if group_schema:
from src.section_parser import get_candidate_blocks_for_schema
schema_candidates = get_candidate_blocks_for_schema(group_schema)
catalog_all = _load_catalog()
for cand_id in schema_candidates:
cand = next((b for b in catalog_all if b.get("id") == cand_id), None)
if cand:
zone_schema_match = cand
break
best_match = zone_tag_match or zone_schema_match
if zone_tag_match and zone_schema_match:
best_match = zone_tag_match
match_type = "tag_match"
logger.info(f"[V-1] {role}: tag={zone_tag_match['id']}, schema={zone_schema_match['id']} → tag 우선")
elif zone_tag_match:
match_type = "tag_match"
elif zone_schema_match:
match_type = "schema_match"
best_match = zone_schema_match
else:
match_type = None
if best_match:
ref_html = generate_design_reference(
block_id=best_match["id"],
variant="default",
catalog_entry=best_match,
)
schema_info = best_match.get("schema", {})
role_refs = [{
"block_id": best_match["id"],
"variant": "default",
"visual_type": match_type or "fallback",
"schema_info": schema_info,
"design_reference_html": ref_html,
"topic_id": topic_ids[0],
"supporting_topic_ids": topic_ids[1:],
"is_hierarchical": True,
}]
logger.info(
f"[V-1] {role}: {match_type}{best_match['id']} "
f"(topics {topic_ids} → 블록 1개)"
)
else:
# tag도 schema도 없음 → 기존 fallback: 꼭지별 블록 선택
if not role_refs:
n_topics = len(topic_ids)
available_for_topics = total_height_px - gap_between * max(0, n_topics - 1)
min_block_height = min(
(b.get("min_height_px", 0) for b in _load_catalog() if b.get("min_height_px", 0) > 0),
default=1,
)
per_topic_height = max(min_block_height, available_for_topics // max(1, n_topics))
role_refs = []
for tid in topic_ids:
topic = topic_map.get(tid, {})
relation_type = topic.get("relation_type", "none")
expression_hint = topic.get("expression_hint", "")
selection = select_reference_block(
relation_type=relation_type,
expression_hint=expression_hint,
container_height_px=per_topic_height,
zone=zone,
role=role,
topic_count=1,
topic_titles=[topic.get("title", "")],
)
ref_html = generate_design_reference(
block_id=selection["block_id"],
variant=selection["variant"],
catalog_entry=selection["catalog_entry"],
)
schema_info = selection["catalog_entry"].get("schema", {})
role_refs.append({
"block_id": selection["block_id"],
"variant": selection["variant"],
"visual_type": selection["visual_type"],
"schema_info": schema_info,
"design_reference_html": ref_html,
"topic_id": tid,
})
logger.info(
f"[V-1] {role}/꼭지{tid}: {selection['block_id']} "
f"(visual_type={selection['visual_type']}, variant={selection['variant']}, "
f"budget={per_topic_height}px)"
)
references[role] = role_refs

View File

@@ -59,6 +59,17 @@ def get_image_sizes(content: str, base_path: str) -> list[dict[str, Any]]:
abs_path = found[0]
logger.info(f"이미지 경로 재탐색 성공: {filename}{abs_path}")
# samples/images/, samples/mdx_batch/ 에서도 탐색
if not abs_path.exists():
filename = Path(rel_path).name
for search_dir in [Path("samples/images"), Path("samples/mdx_batch")]:
if search_dir.exists():
found = list(search_dir.rglob(filename))
if found:
abs_path = found[0]
logger.info(f"이미지 경로 확장 탐색 성공: {filename}{abs_path}")
break
if not abs_path.exists():
logger.warning(f"이미지 파일 미발견: {abs_path}")
images.append({

View File

@@ -30,33 +30,21 @@ KEI_PROMPT = (
"## 3단계: 슬라이드 스토리라인 설계\n"
"핵심 메시지를 전달하기 위한 **흐름**을 설계해줘.\n"
"각 꼭지에 purpose를 부여하고, topics 배열에 기록.\n\n"
"## 4단계: 레이아웃 유형 선택 + 페이지 구조 판단\n"
"먼저 콘텐츠에 맞는 **레이아웃 유형**을 선택하라:\n\n"
"### 유형 A: 배경 + 본심 + 첨부(sidebar) + 결론\n"
"- 참조자료(용어 정의, 부록 등)가 **별도로 존재**하는 콘텐츠\n"
"- 좌측 body(배경+본심) + 우측 sidebar(첨부) + 하단 결론\n"
"- page_structure 키: 배경, 본심, 첨부, 결론\n\n"
"### 유형 B: 본심1(상단) + 본심2(하단 2분할) + 결론\n"
"- 참조자료 없이 **본문 흐름만**으로 구성되는 콘텐츠\n"
"- 배경/첨부가 없거나 억지로 만들어야 하면 이 유형 선택\n"
"- 상단: 핵심 내용 전체폭 (이미지가 있으면 좌텍스트+우이미지 나란히)\n"
"- 하단: 세부 내용 2분할 (좌/우)\n"
"- page_structure 키: 자유 (예: 핵심목표, 프로세스변화, 기대효과, 결론)\n"
"- 결론 키는 반드시 '결론'\n\n"
"선택한 유형을 **layout_template** 필드에 'A' 또는 'B'로 기록하라.\n\n"
"### 역할별 규칙 (유형 A)\n"
"- **본심**: 이 페이지가 말하려는 핵심. 가장 큰 공간.\n"
"- **배경**: 본심을 이해하기 위한 도입. 간결하게.\n"
"- **첨부**: 본심을 보조하는 참조 정보. sidebar 배치. role: 'reference'.\n"
"- **결론**: 핵심 한 줄. footer.\n\n"
"### 역할별 규칙 (유형 B)\n"
"- 상단 역할: 핵심 내용. 전체폭. zone: 'top'\n"
"- 하단 좌측: zone: 'bottom_left'\n"
"- 하단 우측: zone: 'bottom_right'\n"
"- 결론: zone: 'footer'\n\n"
"각 역할에 해당하는 topic_ids와 **공간 비중(weight, 합계 1.0)**을 결정하라.\n"
"**콘텐츠에 따라 비중은 매번 달라진다. 고정값이 아니다.**\n"
"page_structure 필드에 기록.\n\n"
"## 4단계: 꼭지별 성격 판단\n"
"각 꼭지에 대해 다음을 판단하라:\n\n"
"### sidebar 판단\n"
"- 이 꼭지의 내용이 **본문과 독립된 참조 정보**(용어 정의, 개념 비교, 참조 테이블)인가?\n"
"- 독립 참조 → role: 'reference' (sidebar 후보)\n"
"- 본문 흐름의 일부 → role: 'flow'\n\n"
"### 팝업 판단\n"
"- <details> 안에 있는 콘텐츠 → 팝업 처리 대상\n"
"- 너무 세부적인 내용 → 팝업으로 분리 가능\n\n"
"### 핵심요약\n"
"- :::note[핵심 요약] 등의 결론 텍스트가 있으면 **conclusion_text** 필드에 원본 그대로 기록\n"
"- conclusion_text는 슬라이드 하단 footer에 자동 배치됨\n\n"
"**주의: page_structure, zone, 영역 배치는 판단하지 마라.**\n"
"**영역과 zone은 코드가 블록 매칭을 통해 결정한다.**\n"
"**너는 꼭지 추출 + 각 꼭지의 성격(reference/flow, 팝업 여부)만 판단하라.**\n\n"
"## 원본 텍스트 보존 원칙 (절대 규칙)\n"
"- **제목(##, ###)은 원본 그대로 사용하라. 절대 바꾸지 마라.**\n"
" 원본이 '## 1. DX의 궁극적 목표'이면 꼭지 제목도 'DX의 궁극적 목표'.\n"
@@ -70,61 +58,76 @@ KEI_PROMPT = (
"## 배치 규칙\n"
"- 참조 정보(용어 정의 등)는 role: 'reference'로 표시 → 사이드바 배치\n"
"- 본문 흐름은 role: 'flow' → 메인 영역 배치\n"
"- 결론은 layer: 'conclusion' → 하단 배치\n"
"- 결론/핵심요약은 conclusion_text 필드에 기록. page_structure에 넣지 마라.\n"
"- detail_target: true는 정말로 별도로 봐야 하는 상세 데이터에만 사용\n"
"- 이미지/표가 있으면 images[], tables[]에 기록\n"
"- 1페이지 적정 꼭지: 5개. 분량 적으면 1페이지로.\n"
"- **슬라이드 제목(title)과 첫 번째 꼭지 제목은 달라야 한다.** 슬라이드 제목은 전체 주제, 꼭지 제목은 해당 위치의 구체적 내용.\n\n"
"## 출력 형식 (JSON만)\n"
"layout_template에 따라 page_structure가 달라진다.\n\n"
"유형 A 예시:\n"
"**page_structure는 출력하지 마라. 영역/zone 배치는 코드가 결정한다.**\n\n"
"```json\n"
'{"title": "제목", '
'{"title": "슬라이드 제목 (MDX title 또는 전체 주제)", '
'"core_message": "핵심 메시지", '
'"conclusion_text": "핵심 요약 원본 텍스트 (:::note 등에서 추출. 없으면 빈 문자열)", '
'"total_pages": 1, '
'"layout_template": "A", '
'"page_structure": {'
'"본심": {"topic_ids": [2, 3], "weight": 0.60}, '
'"배경": {"topic_ids": [1], "weight": 0.15}, '
'"첨부": {"topic_ids": [4], "weight": 0.15}, '
'"결론": {"topic_ids": [5], "weight": 0.10}}, '
'"topics": ['
'{"id": 1, "title": "꼭지 제목", "summary": "요약", '
'{"id": 1, "title": "꼭지 제목 (원본 그대로)", "summary": "요약", '
'"purpose": "문제제기|근거사례|핵심전달|용어정의|결론강조|구조시각화", '
'"source_hint": "원본에서 이 위치에 가져올 텍스트 범위 설명", '
'"source_hint": "원본에서 이 꼭지에 해당하는 텍스트 범위 설명", '
'"layer": "intro|core|supporting|conclusion", '
'"role": "flow|reference", '
'"section_title": "sidebar에 표시할 섹션 제목 (reference일 때만. 예: 용어 정의, 참고 자료)", '
'"emphasis": true, "direction": "vertical|horizontal|flexible", '
'"content_type": "text|image|table|mixed", '
'"detail_target": false, "page": 1}], '
'"images": [{"topic_id": 1, "role": "key|supporting", "has_text": false, "description": "이미지 설명"}], '
'"tables": [{"topic_id": 2, "rows": 5, "cols": 3, "fits_single_page": true, "description": "표 설명"}]}\n'
"```\n\n"
"유형 B 예시:\n"
"```json\n"
'{"title": "제목", '
'"core_message": "핵심 메시지", '
'"total_pages": 1, '
'"layout_template": "B", '
'"page_structure": {'
'"핵심목표": {"zone": "top", "topic_ids": [1], "weight": 0.35}, '
'"프로세스변화": {"zone": "bottom_left", "topic_ids": [2], "weight": 0.25}, '
'"기대효과": {"zone": "bottom_right", "topic_ids": [3], "weight": 0.25}, '
'"결론": {"zone": "footer", "topic_ids": [4], "weight": 0.15}}, '
'"topics": [...],'
'"images": [...]}\n'
"```\n\n"
"## 콘텐츠:\n"
)
def _detect_structure_hints(content: str) -> str:
"""MDX 구조에서 유형 판단 힌트를 자동 감지."""
hints = []
content_lower = content.lower()
# 용어 정의 섹션 감지
if re.search(r'##\s*\d*\.?\s*용어\s*정의', content):
hints.append("[구조 힌트] '용어 정의' 섹션 감지 → 유형 A 후보")
if re.search(r'##\s*\d*\.?\s*개념\s*비교', content):
hints.append("[구조 힌트] '개념 비교' 섹션 감지 → 유형 A 후보")
# sidebar 마크다운 감지
if 'sidebar:' in content[:200]:
pass # frontmatter의 sidebar는 Starlight 설정이므로 무시
# <details> 감지
if '<details>' in content:
hints.append("[구조 힌트] <details> 참고 사례 감지 → 팝업 처리 대상 (유형 선택과 무관)")
# 표 감지
if '|' in content and '---' in content:
hints.append("[구조 힌트] 표(테이블) 감지 → 비교 구조")
# 이미지 감지
if re.search(r'!\[.*?\]\(.*?\)', content):
hints.append("[구조 힌트] 이미지 감지 → 이미지 배치 필요")
# A 후보 힌트가 없으면 B 유력
if not any("유형 A 후보" in h for h in hints):
hints.append("[구조 힌트] 독립 참조 섹션 없음 → 유형 B 유력")
return "\n".join(hints) + "\n\n"
async def classify_content(content: str) -> dict[str, Any] | None:
"""1단계: Kei API를 통해 꼭지를 추출하고 분석한다.
Kei API만 사용. fallback 없음. 실패 시 None → pipeline에서 에러.
"""
result = await _call_kei_api(content)
# MDX 구조 힌트를 content 앞에 추가
hints = _detect_structure_hints(content)
result = await _call_kei_api(hints + content)
if result:
logger.info(
f"[Kei API] 꼭지 추출 완료: {result.get('title', '')}, "

View File

@@ -141,12 +141,23 @@ async def generate_slide(
if errors:
return {"_errors": errors}
# popup_id 부여 (Stage 0 시점)
from src.pipeline_context import PopupItem
raw_popups = result.get("popups", [])
popup_items = []
for pi, rp in enumerate(raw_popups, 1):
popup_items.append(PopupItem(
popup_id=f"popup_{pi}",
title=rp.get("title", ""),
content=rp.get("content", ""),
))
return {
"normalized": NormalizedContent(
clean_text=result["clean_text"],
title=result["title"],
images=result["images"],
popups=result["popups"],
popups=popup_items,
tables=result["tables"],
sections=result["sections"],
),
@@ -176,6 +187,7 @@ async def generate_slide(
original_title = context.normalized.title or analysis_raw.get("title", "")
analysis = Analysis(
core_message=analysis_raw.get("core_message", ""),
conclusion_text=analysis_raw.get("conclusion_text", ""),
title=original_title,
total_pages=analysis_raw.get("total_pages", 1),
layout_template=analysis_raw.get("layout_template", "A"),
@@ -200,14 +212,197 @@ async def generate_slide(
if validation_errors:
return {"_errors": validation_errors}
# Phase Y: page_structure는 Kei가 만들지 않음.
# Kei 응답에 page_structure가 있어도 무시.
# 코드가 section_parser + 블록 매칭으로 생성 (Stage 1A 후 별도 단계)
return {
"analysis": analysis,
"topics": topics,
"page_structure": page_structure,
"page_structure": PageStructure(roles={}), # 빈 상태, 아래에서 채움
}
ctx = await run_stage(stage_1a, ctx, "stage_1a", max_retries=2)
# ── Phase Y: 영역 확정 (코드: normalized.sections 기반 + 블록 매칭) ──
from src.section_parser import extract_major_sections, extract_conclusion_text, map_topics_to_sections, classify_group_relations, get_candidate_blocks_for_schema, detect_component_popups
from src.block_reference import _match_by_tags, _load_catalog
# source of truth = normalized.sections (Stage 0 산출물)
norm_sections = ctx.normalized.sections if hasattr(ctx.normalized, 'sections') else []
if hasattr(norm_sections, '__iter__') and norm_sections:
if hasattr(norm_sections[0], 'model_dump'):
norm_sections = [s.model_dump() for s in norm_sections]
elif not isinstance(norm_sections[0], dict):
norm_sections = [dict(s) for s in norm_sections]
major_sections = extract_major_sections(norm_sections)
# popup 대상 sub_title 목록 (컴포넌트 태그가 있던 섹션)
popup_sub_titles = []
component_tags = re.findall(r'<([A-Z]\w+)\s*/>', ctx.raw_content)
if component_tags:
raw_lines = ctx.raw_content.split("\n")
for tag_name in component_tags:
current_section = ""
for line in raw_lines:
if line.strip().startswith("### "):
current_section = re.sub(r'^#{1,3}\s*\d*\.?\d*\s*', '', line.strip()).strip()
if f"<{tag_name}" in line:
if current_section:
popup_sub_titles.append(current_section)
break
# Y-13b: group relation 분류
major_sections = classify_group_relations(
major_sections, normalized_sections=norm_sections,
popup_sub_titles=popup_sub_titles,
)
# conclusion_text: raw MDX에서 추출 또는 기존 값 정제
conclusion_text = ctx.analysis.conclusion_text or ""
if not conclusion_text:
conclusion_text = extract_conclusion_text(ctx.raw_content)
# 선행 불릿 마커 정제 (Kei가 * 포함해서 넣을 수 있음)
if conclusion_text:
conclusion_text = re.sub(r'^[\*•\-]\s*', '', conclusion_text).strip()
conclusion_text = re.sub(r'\*+', '', conclusion_text).strip()
if conclusion_text != (ctx.analysis.conclusion_text or ""):
ctx = ctx.model_copy(update={
"analysis": ctx.analysis.model_copy(update={"conclusion_text": conclusion_text}),
})
# 꼭지-대목차 매핑
topic_dicts = [t.model_dump() for t in ctx.topics]
section_topic_map = map_topics_to_sections(topic_dicts, major_sections)
# 대목차별 묶음으로 블록 tag 매칭 → 영역 확정
# 블록 매칭 기준: Kei topic 수가 아니라 normalized sub_titles 수
catalog = _load_catalog()
page_struct_roles = {}
zone_names = ["top", "bottom"]
# major_sections를 title로 빠르게 찾기
major_sec_map = {s["title"]: s for s in major_sections}
for i, (sec_title, tids) in enumerate(section_topic_map.items()):
# sub_titles = normalized.sections에서 파싱된 소항목 목록
sec = major_sec_map.get(sec_title, {})
sub_titles = sec.get("sub_titles", [])
# sidebar 판단: 모든 꼭지가 reference면 sidebar
all_reference = all(
t.role == "reference" for t in ctx.topics if t.id in tids
)
if all_reference:
zone = "sidebar"
elif i < len(zone_names):
zone = zone_names[i]
else:
zone = f"bottom_{i}"
# 블록 매칭: sub_titles 수 기준 (Kei topic 수 아님)
slot_count = len(sub_titles) if sub_titles else len(tids)
tag_match = _match_by_tags(catalog, slot_count, sub_titles, 300, zone)
if tag_match:
logger.info(f"[Phase Y] '{sec_title}' → 블록 {tag_match['id']} (tag_match) 확정")
else:
# Y-13d: tag 매칭 실패 → group schema 후보로 블록 찾기
group_schema = sec.get("group_schema", "")
schema_candidates = get_candidate_blocks_for_schema(group_schema)
if schema_candidates:
# catalog에서 첫 번째 존재하는 후보 선택
for cand_id in schema_candidates:
cand = next((b for b in catalog if b.get("id") == cand_id), None)
if cand:
tag_match = cand # schema 기반 선택
logger.info(f"[Y-13d] '{sec_title}' → schema={group_schema} → 블록 {cand_id}")
break
if not tag_match:
logger.warning(f"[Y-13d] '{sec_title}' → schema={group_schema} → 블록 매칭 실패")
# weight: 콘텐츠 양(content 길이) 기반
sec_content_len = len(sec.get("content", ""))
page_struct_roles[sec_title] = {
"zone": zone,
"topic_ids": tids,
"weight": sec_content_len,
"sub_titles": sub_titles,
"sub_types": sec.get("sub_types", []),
"group_schema": sec.get("group_schema", ""),
}
# weight를 비율로 변환 (합계 1.0)
total_content = sum(info["weight"] for info in page_struct_roles.values())
if total_content > 0:
for role in page_struct_roles:
page_struct_roles[role]["weight"] = round(page_struct_roles[role]["weight"] / total_content, 2)
else:
# content가 없으면 균등 분배
n = len(page_struct_roles)
for role in page_struct_roles:
page_struct_roles[role]["weight"] = round(1.0 / n, 2)
# Phase Y: layout_template도 코드가 결정
# sidebar zone이 있으면 Type A, 없으면 Type B
has_sidebar = any(
info.get("zone") == "sidebar" for info in page_struct_roles.values()
)
determined_layout = "A" if has_sidebar else "B"
logger.info(f"[Phase Y] 영역 확정: {list(page_struct_roles.keys())} → layout={determined_layout}")
ctx = ctx.model_copy(update={
"page_structure": PageStructure(roles=page_struct_roles),
"mdx_sections": major_sections, # normalized.sections 기반 대목차 (assembler용)
"analysis": ctx.analysis.model_copy(update={"layout_template": determined_layout}),
})
# Phase Y: page_structure 검증 (section_parser가 만든 결과)
from src.validators import validate_page_structure
ps_errors = validate_page_structure(page_struct_roles)
if ps_errors:
logger.warning(f"[Phase Y] page_structure 검증 경고: {ps_errors}")
# Y-14: 컴포넌트 popup 감지 + target_role 확정
component_popups = detect_component_popups(ctx.raw_content, ctx.base_path or "samples/mdx")
if component_popups:
from src.pipeline_context import PopupItem
existing_popups = list(ctx.normalized.popups or [])
# target_role 결정: raw MDX에서 컴포넌트 태그가 어느 ## 섹션에 있었는지
raw_lines = ctx.raw_content.split("\n")
for cp in component_popups:
tag = cp.get("tag", f"<{cp['name']} />")
target_role = None
current_section = None
for line in raw_lines:
if line.strip().startswith("## "):
# ## 번호 제거: "## 2. DX 기반..." → "DX 기반..."
# "## 2. DX 기반..." → "DX 기반..."
sec_title = re.sub(r'^#{1,3}\s*\d*\.?\s*', '', line.strip()).strip()
# page_structure roles에서 매칭
for rname in page_struct_roles:
if sec_title and len(sec_title) >= 3 and sec_title[:6] in rname:
current_section = rname
break
if tag.replace(" ", "") in line.replace(" ", ""):
target_role = current_section
break
existing_popups.append(PopupItem(
popup_id=f"comp_{cp['name']}",
title=f"상세: {cp['name']}",
content=cp["content_html"],
source=cp["source"],
is_component=True,
target_role=target_role,
))
logger.info(f"[Y-14] 컴포넌트 popup: {cp['name']} → target_role='{target_role}'")
ctx = ctx.model_copy(update={
"normalized": ctx.normalized.model_copy(update={"popups": existing_popups}),
})
logger.info(f"[Y-14] 컴포넌트 popup {len(component_popups)}개 추가")
# ── Stage 1B: 컨셉 구체화 ──
yield {"event": "progress", "data": "1.5/7 컨셉 구체화 중..."}
@@ -449,7 +644,7 @@ async def generate_slide(
build_enhancement_report, calculate_sub_layout,
EnhancementAnalysis,
)
from src.block_assembler import assemble_slide_html
from src.block_assembler import assemble_slide_html_final as assemble_slide_html
from src.slide_measurer import measure_rendered_heights
refs_dict = {}
@@ -520,6 +715,9 @@ async def generate_slide(
# ── filled→측정→Kei 재판단 루프 (최대 3회) ──
kei_decisions = []
updated_containers = dict(context.containers)
fit_analysis = None
filled_measurement = {}
font_scale = 1.0 # fit 루프에서 축소
MAX_FIT_RETRIES = 3
for fit_round in range(MAX_FIT_RETRIES):
@@ -533,8 +731,8 @@ async def generate_slide(
"containers": updated_containers,
})
# ── filled: 컨테이너에 블록+텍스트 채움 ──
filled_html = assemble_slide_html(context)
# ── filled: 컨테이너에 블록+텍스트 채움 (측정용: overflow:auto) ──
filled_html = assemble_slide_html(context, measure_mode=True, font_scale=font_scale)
(steps_dir / f"stage_1_8_filled{'_r'+str(fit_round) if fit_round else ''}.html").write_text(
filled_html.replace('</head><body>', '</head><body>\n'
f'<div style="font-size:16px;font-weight:bold;margin-bottom:4px;">'
@@ -642,6 +840,11 @@ async def generate_slide(
f"{r}={ci.height_px}px" for r, ci in updated_containers.items()
))
# ── Phase Y: font_scale 축소 (재배분만으로 부족할 때) ──
# 재배분 후에도 여전히 overflow면 font를 줄임
font_scale = max(0.7, font_scale - 0.1)
logger.info(f"[Stage 1.8] round {fit_round+1}: font_scale → {font_scale:.1f}")
# ── Kei 에스컬레이션: overflow 있으면 팝업 분리 판단 요청 ──
# calculate_fit의 needs_escalation 또는 Selenium 측정의 실제 overflow
if fit_analysis.needs_escalation or has_overflow:
@@ -676,15 +879,19 @@ async def generate_slide(
logger.info(f"[Stage 1.8] round {fit_round+1}: 재배분으로 해결됨")
break
# Step 4: 보강 제안 분석
enhancements = analyze_enhancements(
topics=[t.model_dump() for t in context.topics],
page_structure=context.page_structure.roles,
references=refs_dict,
analysis=fit_analysis,
normalized=normalized,
core_message=core_message,
)
# Step 4: 보강 제안 분석 (fit_analysis가 있을 때만)
if fit_analysis:
enhancements = analyze_enhancements(
topics=[t.model_dump() for t in context.topics],
page_structure=context.page_structure.roles,
references=refs_dict,
analysis=fit_analysis,
normalized=normalized,
core_message=core_message,
)
else:
enhancements = EnhancementAnalysis()
logger.info("[Stage 1.8] overflow 없음 — 보강 분석 스킵")
# Step 5: Kei에게 보강 제안 확인 요청
if enhancements.enhancements:
@@ -744,15 +951,19 @@ async def generate_slide(
# 재배분된 컨테이너 크기 업데이트
updated_containers = {}
for role, ci in context.containers.items():
new_h = fit_analysis.redistribution.get(role, ci.height_px) if fit_analysis.redistribution else ci.height_px
if fit_analysis and fit_analysis.redistribution:
new_h = fit_analysis.redistribution.get(role, ci.height_px)
else:
new_h = ci.height_px
updated_containers[role] = ci.model_copy(update={
"height_px": int(new_h),
})
# Step 7: 세부 컨테이너 배치 계산
sub_layouts = {}
for role, rf in fit_analysis.roles.items():
new_h = fit_analysis.redistribution.get(role, rf.allocated_px) if fit_analysis.redistribution else rf.allocated_px
fit_roles = fit_analysis.roles if fit_analysis else {}
for role, rf in fit_roles.items():
new_h = fit_analysis.redistribution.get(role, rf.allocated_px) if fit_analysis and fit_analysis.redistribution else rf.allocated_px
ci = context.containers.get(role)
if not ci or not rf.topic_fits:
continue
@@ -801,7 +1012,7 @@ async def generate_slide(
popup_refs = _re.findall(r'\[팝업:\s*([^\]]+)\]', st_text)
for pr in popup_refs:
# 팝업 원본 찾기
popup = next((p for p in popups if pr in p.get("title", "")), None)
popup = next((p for p in popups if pr in (p.title if hasattr(p, 'title') else p.get("title", ""))), None)
if not popup:
continue
# 공란 계산: V'-4 적용 후 높이 (결론 바로 위까지 채움)
@@ -842,7 +1053,7 @@ async def generate_slide(
continue # 공간 부족하면 건너뜀
summary = await call_kei_summarize_popup(
popup_title=pr,
popup_content=popup.get("content", ""),
popup_content=popup.content if hasattr(popup, 'content') else popup.get("content", ""),
available_width_px=available_w,
available_height_px=available_h,
font_size=fs,
@@ -891,6 +1102,7 @@ async def generate_slide(
"containers": updated_containers,
"sub_layouts": sub_layouts,
"measurement": filled_measurement,
"font_scale": font_scale, # Phase Y: fit 루프에서 확정된 font 축소 비율
"fit_result": {
"roles": {
role: {
@@ -899,10 +1111,10 @@ async def generate_slide(
"allocated_px": rf.allocated_px,
"shortfall_px": rf.shortfall_px,
}
for role, rf in fit_analysis.roles.items()
for role, rf in (fit_analysis.roles.items() if fit_analysis else {}.items())
},
"redistribution": fit_analysis.redistribution,
"needs_escalation": fit_analysis.needs_escalation,
"redistribution": fit_analysis.redistribution if fit_analysis else {},
"needs_escalation": fit_analysis.needs_escalation if fit_analysis else False,
},
"enhancement_result": {
"kei_decisions": kei_decisions,
@@ -972,9 +1184,10 @@ async def generate_slide(
async def stage_2(context: PipelineContext) -> dict:
# Phase X-BX': Type B는 code_assembled 직접 사용, Sonnet 재구성 스킵
if context.analysis.layout_template in ("B", "B'", "B''"):
from src.block_assembler import assemble_slide_html
generated = assemble_slide_html(context)
logger.info("[Stage 2] Type B: code_assembled 직접 사용 (Sonnet 스킵)")
from src.block_assembler import assemble_slide_html_final
fs = context.font_scale if hasattr(context, 'font_scale') else 1.0
generated = assemble_slide_html_final(context, font_scale=fs)
logger.info(f"[Stage 2] Type B: slide-base + 블록 (font_scale={fs:.1f})")
return {"generated_html": generated}
# Type A: 기존 Sonnet 재구성 코드 그대로
@@ -1094,7 +1307,7 @@ async def generate_slide(
capture_slide_screenshot, context.rendered_html
)
quality_score = 100
quality_score = -1 # 비전 미평가 시 -1 (거짓 100점 방지)
if screenshot_b64:
analysis_dict = {
"topics": [t.model_dump() for t in context.topics],
@@ -1111,6 +1324,8 @@ async def generate_slide(
"localization": f"품질 {quality_score}/100 < 30",
"instruction": "출력 차단",
}]}
else:
logger.warning("[Stage 4] 비전 품질 평가 실패 — quality_score=-1 (미평가)")
return {
"measurement": measurement,
@@ -1139,7 +1354,10 @@ async def generate_slide(
]
logger.warning(f"[Stage 5] overflow 감지: {overflow_zones} — 결과물에 경고 포함")
if quality < 30:
if quality < 0:
# 비전 미평가: 차단하지 않고 경고만. Selenium overflow 검사는 통과한 상태.
logger.warning(f"[Stage 5] 비전 미평가 (quality={quality}) — Selenium 측정만으로 통과")
elif quality < 30:
logger.error(f"[Stage 5] 품질 {quality}/100 < 30 — 출력 차단")
yield {"event": "error", "data": f"품질 검증 미달 ({quality}/100). 출력 차단."}
return
@@ -1149,22 +1367,44 @@ async def generate_slide(
html = embed_images(html, ctx.base_path)
ctx = ctx.model_copy(update={"rendered_html": html})
ctx.save_snapshot("final")
# final.html 저장
# Stage 5: popup_file 확정 (save_snapshot 전에 완료)
run_dir = ctx.get_run_dir()
run_dir.mkdir(parents=True, exist_ok=True)
popups = ctx.normalized.popups
if popups:
updated_popups = []
for i, popup in enumerate(popups, 1):
popup_title = popup.title
popup_content = popup.content
pid = popup.popup_id or f"popup_{i}"
safe_title = "".join(c for c in popup_title if c.isalnum() or c in "_ -").strip()
popup_filename = f"첨부{i}_{safe_title}.html"
# popup_file 확정 → 새 PopupItem으로 (Pydantic immutable 대응)
updated_popups.append(popup.model_copy(update={"popup_file": popup_filename}))
ctx = ctx.model_copy(update={
"normalized": ctx.normalized.model_copy(update={"popups": updated_popups}),
})
popups = ctx.normalized.popups # 업데이트된 참조
ctx.save_snapshot("final")
# stage_4 검증판을 final 시점 context로 재생성 (popup_file 등 반영)
from src.step_visualizer import generate_step_html
try:
generate_step_html(ctx, "stage_4")
except Exception as e:
logger.warning(f"[Stage 5] stage_4 재생성 실패: {e}")
# final.html 저장
(run_dir / "final.html").write_text(html, encoding="utf-8")
# Phase T: 팝업(상세 내용)을 별도 HTML로 분리 저장
popups = ctx.normalized.popups
if popups:
for i, popup in enumerate(popups, 1):
popup_title = popup.get("title", f"첨부{i}")
popup_content = popup.get("content", "")
# 파일명에서 특수문자 제거
safe_title = "".join(c for c in popup_title if c.isalnum() or c in "_ -").strip()
popup_filename = f"첨부{i}_{safe_title}.html"
popup_title = popup.title
popup_content = popup.content
popup_filename = popup.popup_file or f"첨부{i}.html"
# TP-6: 첨부 HTML에 디자인 토큰 적용
import re as _re
# JSX style={{}} 잔여 정리
@@ -1179,27 +1419,27 @@ async def generate_slide(
# 콘텐츠 유형별 CSS
if has_table:
# 3열 비교표: 양쪽 동일 너비, 중앙 맞춤, bold+br 지원
content_css = """
table {{ border-collapse: collapse; width: 100%; margin: 16px 0; font-size: 13px; table-layout: fixed; }}
th {{ background: var(--color-primary); color: #fff; font-weight: 700; padding: 10px 14px; text-align: center; border: 1px solid #334155; }}
th:nth-child(1), th:nth-child(3) {{ width: 42%; }}
th:nth-child(2) {{ width: 16%; }}
td {{ padding: 10px 14px; border: 1px solid var(--color-border); vertical-align: middle; text-align: center; line-height: 1.6; }}
tr:nth-child(even) {{ background: var(--color-bg-subtle); }}"""
content_css = (
"table { border-collapse: collapse; width: 100%; margin: 16px 0; font-size: 13px; table-layout: fixed; }\n"
"th { background: var(--color-primary); color: #fff; font-weight: 700; padding: 10px 14px; text-align: center; border: 1px solid #334155; }\n"
"th:nth-child(1), th:nth-child(3) { width: 42%; }\n"
"th:nth-child(2) { width: 16%; }\n"
"td { padding: 10px 14px; border: 1px solid var(--color-border); vertical-align: middle; text-align: center; line-height: 1.6; }\n"
"tr:nth-child(even) { background: var(--color-bg-subtle); }"
)
elif has_list:
# 카드형 리스트: 항목별 박스, 하위 항목은 인라인
content_css = """
ul {{ padding-left: 0; margin: 12px 0; list-style: none; }}
li {{ margin-bottom: 12px; font-size: 14px; background: #f8fafc; border: 1px solid var(--color-border); border-radius: 8px; padding: 14px 18px; }}
li ul {{ margin-top: 8px; margin-bottom: 0; padding-left: 0; }}
li li {{ background: transparent; border: none; border-radius: 0; padding: 2px 0; margin-bottom: 4px; font-size: 13px; color: #475569; }}
li li::before {{ content: "\\2022"; color: var(--color-accent); margin-right: 8px; }}"""
content_css = (
"ul { padding-left: 0; margin: 12px 0; list-style: none; }\n"
"li { margin-bottom: 12px; font-size: 14px; background: #f8fafc; border: 1px solid var(--color-border); border-radius: 8px; padding: 14px 18px; }\n"
"li ul { margin-top: 8px; margin-bottom: 0; padding-left: 0; }\n"
"li li { background: transparent; border: none; border-radius: 0; padding: 2px 0; margin-bottom: 4px; font-size: 13px; color: #475569; }\n"
'li li::before { content: "\\2022"; color: var(--color-accent); margin-right: 8px; }'
)
else:
# 기본 (텍스트)
content_css = """
ul {{ padding-left: 20px; margin: 8px 0; }}
li {{ margin-bottom: 4px; font-size: 13px; }}"""
content_css = (
"ul { padding-left: 20px; margin: 8px 0; }\n"
"li { margin-bottom: 4px; font-size: 13px; }"
)
popup_html = f"""<!DOCTYPE html>
<html lang="ko">

View File

@@ -23,12 +23,30 @@ from pydantic import BaseModel, Field, model_validator
# 하위 모델
# ──────────────────────────────────────
class PopupItem(BaseModel):
"""팝업/첨부 항목.
생애주기:
Stage 0 → title, content 확정
Y-14 감지 → popup_id 확정, is_component, source
Stage 2 → popup_id로 참조 (popup_file은 아직 없음)
Stage 5 저장 → popup_file 확정 (run_dir + 파일명 정책)
"""
popup_id: str = "" # 감지 시점에 확정 (예: "popup_1", "comp_DxEffect")
title: str = ""
content: str = ""
source: str | None = None
is_component: bool = False
target_role: str | None = None # Y-14에서 확정: 이 popup이 속하는 role 이름
popup_file: str | None = None # Stage 5에서 확정
class NormalizedContent(BaseModel):
"""Stage 0 출력: MDX 정규화 결과."""
clean_text: str = ""
title: str = ""
images: list[dict[str, str]] = Field(default_factory=list)
popups: list[dict[str, str]] = Field(default_factory=list)
popups: list[PopupItem] = Field(default_factory=list)
tables: list[dict[str, Any]] = Field(default_factory=list)
sections: list[dict[str, Any]] = Field(default_factory=list)
@@ -61,6 +79,7 @@ class PageStructure(BaseModel):
class Analysis(BaseModel):
"""Stage 1A 출력: Kei 분석 결과 전체."""
core_message: str = ""
conclusion_text: str = "" # Phase Y: slide-base footer에 들어갈 핵심요약 원본 텍스트
title: str = ""
total_pages: int = 1
layout_template: str = "A" # Phase X-B: Kei가 선택한 유형 (A 또는 B)
@@ -166,6 +185,9 @@ class PipelineContext(BaseModel):
topics: list[Topic] = Field(default_factory=list)
page_structure: PageStructure = Field(default_factory=PageStructure)
# ── Phase Y: MDX 원본 섹션 (## 파싱 결과) ──
mdx_sections: list[dict[str, Any]] = Field(default_factory=list) # [{title, content, level, is_intro}]
# ── Stage 1.5a ──
font_hierarchy: FontHierarchy = Field(default_factory=FontHierarchy)
container_ratio: tuple[int, int] = (0, 0) # Stage 1.5a에서 설정 (body_pct, sidebar_pct)
@@ -178,6 +200,7 @@ class PipelineContext(BaseModel):
# ── Stage 1.8 ──
fit_result: dict[str, Any] = Field(default_factory=dict)
font_scale: float = 1.0 # Phase Y: fit 루프에서 확정된 font 축소 비율
enhancement_result: dict[str, Any] = Field(default_factory=dict)
sub_layouts: dict[str, Any] = Field(default_factory=dict) # role → ContainerLayout 직렬화

View File

@@ -367,7 +367,7 @@ def render_multi_page(layout_concept: dict[str, Any]) -> str:
"page_number": page_idx + 1,
})
base_template = env.get_template("slide-base.html")
base_template = env.get_template("blocks/slide-base.html")
html = base_template.render(
slide_title=title,
pages=pages_rendered,
@@ -425,7 +425,7 @@ def render_slide(layout: dict[str, Any]) -> str:
blocks_grouped = _group_blocks_by_area(blocks_raw)
base_template = env.get_template("slide-base.html")
base_template = env.get_template("blocks/slide-base.html")
html = base_template.render(
slide_title=layout.get("title", ""),
pages=[{

573
src/section_parser.py Normal file
View File

@@ -0,0 +1,573 @@
"""Phase Y: 영역 확정 모듈.
normalized.sections(Stage 0 산출물)를 기반으로 ## 대목차 구조를 파악하고,
Kei 꼭지를 대목차에 매핑하여 영역을 확정한다.
source of truth = normalized.sections (Stage 0)
raw MDX는 사용하지 않음 (보존용/증거용으로만 존재).
용도:
- Kei 꼭지를 대목차에 매핑
- 대목차별 묶음으로 블록 tag 매칭
- 영역 확정 (코드가, Kei가 아님)
"""
from __future__ import annotations
import re
import logging
from typing import Any
logger = logging.getLogger(__name__)
def extract_major_sections(normalized_sections: list[dict]) -> list[dict]:
"""normalized.sections에서 ## 대목차(level=2)를 추출하고,
각 대목차 아래의 소목차(level=3) content를 합쳐서 반환.
normalized.sections 구조:
[{"level": 2, "title": "DX 시행을 위한 필수 요건", "content": ""},
{"level": 2, "title": "기술(디지털)", "content": "D1: ..."},
{"level": 3, "title": "과정(Process)의 혁신", "content": "D1: ..."}]
반환:
[{"title": "DX 시행을 위한 필수 요건", "content": "기술+사람+자연 합침", "sub_titles": ["기술","사람","자연"]},
{"title": "Process의 혁신과 Product의 변화", "content": "과정+결과 합침", "sub_titles": ["과정","결과"]}]
"""
if not normalized_sections:
return []
# level=2 중 content가 비어있는 것 = 대목차 헤더 (아래 level=2/3이 소속)
# level=2 중 content가 있는 것 = 대목차 헤더가 없는 독립 섹션 (소목차)
# level=3 = 소목차
major_sections = []
current_major = None
for sec in normalized_sections:
level = sec.get("level", 2)
title = sec.get("title", "")
content = sec.get("content", "")
if level == 2 and not content.strip():
# 대목차 헤더 (빈 content = 아래 섹션들의 그룹 헤더)
if current_major:
major_sections.append(current_major)
current_major = {
"title": title,
"content": "",
"sub_titles": [],
}
elif level == 2 and content.strip():
# content가 있는 level=2 = 소목차 또는 독립 섹션
if current_major:
# 현재 대목차 아래의 소목차
current_major["content"] += f"\n{content}" if current_major["content"] else content
current_major["sub_titles"].append(title)
else:
# 대목차 없이 시작된 독립 섹션 (도입부)
current_major = {
"title": title,
"content": content,
"sub_titles": [title],
}
elif level == 3:
# 소목차 → 현재 대목차에 합침
if current_major:
current_major["content"] += f"\n{content}" if current_major["content"] else content
current_major["sub_titles"].append(title)
else:
# 대목차 없는 level=3 (비정상이지만 처리)
current_major = {
"title": title,
"content": content,
"sub_titles": [title],
}
if current_major:
major_sections.append(current_major)
# 빈 섹션 제거
major_sections = [s for s in major_sections if s["content"].strip()]
logger.info(
f"[section_parser] {len(major_sections)}개 대목차: "
+ ", ".join(f'"{s["title"]}" (sub: {s["sub_titles"]})' for s in major_sections)
)
return major_sections
def detect_component_popups(raw_content: str, base_path: str = "") -> list[dict]:
"""Y-14: MDX에서 import된 Astro 컴포넌트를 감지하고 popup 대상으로 등록.
Returns:
[{"name": "DxEffect", "source": "components/dx.astro",
"resolved_path": "실제 파일 경로", "content_html": "astro HTML 내용"}]
"""
from pathlib import Path
popups = []
# import 문 파싱
imports = re.findall(r'import\s+(\w+)\s+from\s+["\']([^"\']+)["\']', raw_content)
# self-closing 태그 사용 여부
used_tags = set(re.findall(r'<(\w+)\s*/>', raw_content))
for name, source in imports:
if name not in used_tags:
continue # import만 하고 사용 안 한 것은 무시
# astro 파일 경로 해석
resolved = ""
content_html = ""
if base_path:
# MDX 기준 상대경로 → 절대경로
mdx_dir = Path(base_path)
candidate = mdx_dir / source
if not candidate.exists():
# samples/src/components/ 에서 찾기
candidate = Path(base_path).parent.parent / "src" / "components" / Path(source).name
if not candidate.exists():
# 프로젝트 루트에서 찾기
candidate = Path("samples/src/components") / Path(source).name
if candidate.exists():
resolved = str(candidate)
raw = candidate.read_text(encoding="utf-8")
# astro frontmatter 제거
if raw.startswith("---"):
end = raw.find("---", 3)
if end > 0:
content_html = raw[end + 3:].strip()
else:
content_html = raw
else:
content_html = raw
popups.append({
"name": name,
"source": source,
"resolved_path": resolved,
"content_html": content_html,
"tag": f"<{name} />",
})
logger.info(f"[Y-14] 컴포넌트 popup 감지: {name}{resolved or source}")
return popups
def _classify_sub_types(
sub_titles: list[str], full_content: str,
normalized_sections: list[dict] | None = None,
popup_sub_titles: list[str] | None = None,
) -> list[dict]:
"""B-1: 각 sub_title의 콘텐츠 유형을 점수 기반 힌트로 판단.
점수 항목:
- 병렬 소목차 구조 (sub_titles 수, 대등성)
- 각 항목 길이 (D2 본문 길이)
- D1/D2 패턴 밀도
- popup/component 존재 여부 (popup_sub_titles)
Returns: [{title: str, sub_type: str}]
"""
results = []
lines = full_content.split("\n")
norm_secs = normalized_sections or []
for st in sub_titles:
st_key = re.sub(r'\*+', '', st.split("(")[0].strip()).lower()
sub_content = ""
# 1차: normalized_sections에서 섹션 title로 매칭
for sec in norm_secs:
sec_title = sec.get("title", "").lower()
if st_key and len(st_key) >= 2 and st_key in sec_title:
sub_content = sec.get("content", "")
break
# 2차: D1: 항목 내 매칭 (sub_title이 D1 항목명인 경우)
if not sub_content:
capturing = False
for line in lines:
d1_match = re.match(r'^D1:\s*(.*)', line.strip())
if d1_match:
d1_text = re.sub(r'\*+', '', d1_match.group(1)).strip().lower()
if capturing:
break
if st_key and len(st_key) >= 2 and st_key in d1_text:
capturing = True
sub_content += line.strip() + "\n"
elif capturing:
stripped = line.strip()
if stripped:
sub_content += stripped + "\n"
# 점수 계산
scores = {
"parallel_card_candidate": 0,
"text_list_candidate": 0,
"visual_detail_candidate": 0,
"table_heavy_candidate": 0,
}
d2_lines = re.findall(r'^D2:', sub_content, re.MULTILINE)
d2_total_len = sum(len(l) for l in re.findall(r'^D2:\s*(.*)', sub_content, re.MULTILINE))
has_table = bool(re.search(r'As-is|To-be|\|.*\|.*\|', sub_content))
is_empty = len(sub_content.strip()) < 10
# parallel_card: 짧은 D2, 항목이 대등
if len(d2_lines) >= 1 and d2_total_len < 200:
scores["parallel_card_candidate"] += 3
if len(sub_titles) >= 3:
scores["parallel_card_candidate"] += 2
# text_list: 긴 D2 본문
if d2_total_len >= 100:
scores["text_list_candidate"] += 3
if len(d2_lines) >= 3:
scores["text_list_candidate"] += 2
# visual_detail: content 비거나 popup/component
if is_empty:
scores["visual_detail_candidate"] += 5
if "컴포넌트" in sub_content or "[팝업:" in sub_content:
scores["visual_detail_candidate"] += 3
# popup_sub_titles에 포함되면 강하게 visual_detail
popup_subs = popup_sub_titles or []
if any(st_key in ps.lower() for ps in popup_subs):
scores["visual_detail_candidate"] += 6
# content가 핵심요약/결론 + D1 1줄 이하면 실질적으로 빈 것 — visual_detail
# D1이 2개 이상이면 실제 본문 콘텐츠로 봄
d1_lines = re.findall(r'^D1:', sub_content, re.MULTILINE)
content_without_markers = re.sub(r'\[핵심요약:[^\]]*\]', '', sub_content).strip()
if len(d1_lines) <= 1 and len(content_without_markers) < 50 and sub_content.strip():
scores["visual_detail_candidate"] += 4
# D1이 여러 개면 본문형 content → text_list 가점
if len(d1_lines) >= 2:
scores["text_list_candidate"] += 3
# table_heavy
if has_table:
scores["table_heavy_candidate"] += 5
# 최고 점수 candidate 선택
best_type = max(scores, key=scores.get)
best_score = scores[best_type]
# 점수가 0이면 content 길이로 fallback
if best_score == 0:
if sub_content.strip():
best_type = "text_list_candidate"
else:
best_type = "visual_detail_candidate"
results.append({"title": st, "sub_type": best_type})
logger.debug(f"[sub_type] '{st}': {best_type} (scores={scores})")
return results
def classify_group_relations(
major_sections: list[dict],
topics: list[dict] | None = None,
normalized_sections: list[dict] | None = None,
popup_sub_titles: list[str] | None = None,
) -> list[dict]:
"""Y-13b: 각 대목차의 sub_titles 간 관계를 판단하여 group_schema를 부여.
규칙 기반 판단 (Kei 없이):
- sub_titles 3개 + 병렬 → parallel_cluster
- sub_titles 2개 + 비대칭 → compare_asymmetric_paired
- sub_titles 2개 + 순서/변화 → sequence_list
- sub_titles 1개 → single_block
- sub_titles 4개+ → card_cluster_N
Returns: major_sections에 group_schema 필드 추가하여 반환
"""
for sec in major_sections:
sub_titles = sec.get("sub_titles", [])
content = sec.get("content", "")
content_lower = content.lower()
n = len(sub_titles)
# sub_titles가 1개 이하지만 content에 D1: 항목이 여러 개면 → 실제 병렬 항목 수
if n <= 1:
d1_items = re.findall(r'^D1:\s*\*?\*?(.+?)\*?\*?\s*$', content, re.MULTILINE)
# 이미지/표 관련 D1 제외
d1_items = [d for d in d1_items if not d.strip().startswith('!') and not d.strip().startswith('As-is')]
if len(d1_items) >= 2:
n = len(d1_items)
sec["sub_titles"] = [re.sub(r'\*+', '', d).strip() for d in d1_items]
sub_titles = sec["sub_titles"]
if n == 0 or n == 1:
sec["group_schema"] = "single_block"
elif n == 3:
sec["group_schema"] = "parallel_cluster"
elif n == 2:
has_table = bool(re.search(r'As-is|To-be|\|.*\|.*\|', content))
compare_hints = ["vs", "비교", "차이", "반면"]
asymmetric_hints = ["혁신", "변화", "변환", "전환"]
process_hints = ["과정", "단계", "수행", "주체"]
sub_text = " ".join(sub_titles).lower()
effect_hints = ["기대효과", "효과", "성과", "결과물"]
all_text = content_lower + " " + sub_text
has_compare = any(h in all_text for h in compare_hints)
has_asymmetric = any(h in all_text for h in asymmetric_hints)
has_process = any(h in all_text for h in process_hints)
has_effect = any(h in all_text for h in effect_hints)
if has_table and has_asymmetric:
sec["group_schema"] = "compare_asymmetric_paired"
elif has_process and has_effect:
sec["group_schema"] = "sequence_plus_visual"
elif has_process:
sec["group_schema"] = "sequence_list"
elif has_compare:
sec["group_schema"] = "compare_paired"
else:
sec["group_schema"] = "compare_paired"
elif n == 4:
sec["group_schema"] = "card_cluster_4"
else:
sec["group_schema"] = f"card_cluster_{n}"
# 시각 앵커 포함 여부 (이미지, 차트, 컴포넌트 등)
has_visual = "이미지" in content or "![" in content or ".png" in content
if has_visual:
sec["group_schema"] += "_plus_visual"
# B-1: subsection typing — 각 sub_title의 콘텐츠 유형을 점수 기반으로 판단
sec["sub_types"] = _classify_sub_types(sub_titles, content, normalized_sections, popup_sub_titles)
logger.info(f"[Y-13b] '{sec['title']}': sub={n}개, schema={sec['group_schema']}, sub_types={[s['sub_type'] for s in sec['sub_types']]}")
return major_sections
# ══════════════════════════════════════
# schema alias: 회귀 안전을 위해 old → new 매핑 유지
# ══════════════════════════════════════
SCHEMA_ALIASES = {
"parallel_3": "parallel_cluster",
"parallel_3_with_image": "parallel_cluster_plus_visual",
"compare_2": "compare_paired",
"compare_asymmetric_2col": "compare_asymmetric_paired",
"process_plus_visual": "sequence_plus_visual",
"process_list": "sequence_list",
"single_section": "single_block",
"card_list_4": "card_cluster_4",
}
def resolve_schema(schema: str) -> str:
"""old schema 이름 → new 이름으로 해소. 이미 new면 그대로 반환."""
return SCHEMA_ALIASES.get(schema, schema)
# ══════════════════════════════════════
# schema → recipe 매핑 (표현 계약)
# recipe = 블록 이름이 아닌, 레이아웃 계약
# ══════════════════════════════════════
SCHEMA_RECIPE_MAP = {
"parallel_cluster": {
"recipe": "single_block",
"block_kind": "parallel_cards",
"blocks": ["prerequisites-3col", "card-compare-3col", "card-icon-desc"],
},
"parallel_cluster_plus_visual": {
"recipe": "two_col_text_visual",
"left_kind": "parallel_cards",
"right_kind": "visual_anchor",
"ratio": "7:3",
"vertical_align": "center",
# direct single-block mapping 금지: p3c는 2층 구조(label+heading)라서
# 1층 구조(목표 제목만)인 plus_visual에서는 부적합.
# composition으로 쓸 가능성은 열어둠 (향후 blocks_composition에 추가 가능).
"blocks_left": ["card-icon-desc", "card-compare-3col", "card-text-grid"],
},
"compare_paired": {
"recipe": "single_block",
"block_kind": "compare_cards",
"blocks": ["compare-detail-gradient", "comparison-2col"],
},
"compare_asymmetric_paired": {
"recipe": "single_block",
"block_kind": "compare_asymmetric",
"blocks": ["process-product-2col", "compare-detail-gradient"],
},
"sequence_list": {
"recipe": "single_block",
"block_kind": "sequence_cards",
"blocks": ["card-step-vertical", "checklist-dark", "card-numbered"],
},
"sequence_plus_visual": {
"recipe": "two_col_text_detail",
"left_kind": "text_list",
"right_kind": "summary_and_popup",
"ratio": "6:4",
"vertical_align": "top",
"blocks_left": ["card-icon-desc", "card-step-vertical", "card-numbered"],
},
"single_block": {
"recipe": "single_block",
"block_kind": "text_list",
"blocks": ["dark-bullet-list", "checklist-dark", "card-numbered"],
},
"card_cluster_4": {
"recipe": "single_block",
"block_kind": "card_grid",
"blocks": ["card-icon-desc", "card-text-grid", "card-numbered"],
},
}
def get_recipe_for_schema(schema: str) -> dict:
"""schema → recipe 표현 계약 반환. alias 자동 해소."""
resolved = resolve_schema(schema)
# _plus_visual suffix 분리: base schema에서 recipe 찾고, visual 플래그 추가
base = resolved.replace("_plus_visual", "")
has_visual = "_plus_visual" in resolved
recipe = SCHEMA_RECIPE_MAP.get(resolved)
if recipe:
return recipe
# base schema로 fallback하되 visual 플래그 추가
recipe = SCHEMA_RECIPE_MAP.get(base)
if recipe and has_visual:
# base recipe를 복사해서 visual 힌트 추가
r = dict(recipe)
r["has_visual"] = True
return r
# card_cluster_N → card_cluster_4 fallback
if base.startswith("card_cluster_"):
return SCHEMA_RECIPE_MAP.get("card_cluster_4", {})
return {}
# C-1: recipe kind ↔ sub_type 호환 규칙
KIND_SUBTYPE_COMPAT = {
"parallel_cards": ["parallel_card_candidate"],
"text_list": ["text_list_candidate"],
"visual_anchor": ["visual_detail_candidate"],
"summary_and_popup": ["visual_detail_candidate"],
"compare_cards": ["parallel_card_candidate", "text_list_candidate"],
"compare_asymmetric": ["text_list_candidate", "table_heavy_candidate"],
"sequence_cards": ["text_list_candidate"],
"card_grid": ["parallel_card_candidate"],
}
def check_kind_compatibility(recipe_kind: str, sub_types: list[dict]) -> bool:
"""recipe의 left_kind/right_kind가 실제 sub_type과 호환되는지 확인."""
compatible = KIND_SUBTYPE_COMPAT.get(recipe_kind, [])
if not compatible:
return True # 규칙 없으면 호환 가정
actual_types = [s.get("sub_type", "") for s in sub_types]
return any(t in compatible for t in actual_types)
def get_candidate_blocks_for_schema(group_schema: str) -> list[str]:
"""Y-13d: group schema에 맞는 블록 후보 ID 목록 반환. recipe 경유.
주의: *_plus_visual schema는 direct single-block 매칭 금지.
이 함수는 recipe 내부의 블록 후보를 반환할 뿐,
실제 선택은 recipe executor가 담당.
"""
recipe = get_recipe_for_schema(group_schema)
if not recipe:
return []
# recipe 유형에 따라 블록 후보 반환
recipe_type = recipe.get("recipe", "")
if recipe_type in ("two_col_text_visual", "two_col_text_detail"):
return recipe.get("blocks_left", [])
else:
return recipe.get("blocks", [])
def extract_conclusion_text(raw_content: str) -> str:
"""raw MDX에서 :::note[핵심 요약] 텍스트만 추출.
이것만 raw MDX에서 가져옴 (normalized에 없을 수 있으므로).
"""
note_match = re.search(r':::note\[([^\]]*)\]\s*([\s\S]*?):::', raw_content)
if note_match:
text = note_match.group(2).strip()
# 마크다운 볼드/불릿 잔여 제거
text = re.sub(r'^\*\s*\*\*', '', text)
text = re.sub(r'\*\*$', '', text)
text = text.strip("* ")
# 선행 불릿 마커(*, •, -) 제거
text = re.sub(r'^[\*•\-]\s*', '', text).strip()
return text
return ""
def map_topics_to_sections(
topics: list[dict],
sections: list[dict],
) -> dict[str, list[int]]:
"""Kei 꼭지들을 대목차 섹션에 매핑.
각 꼭지의 title을 보고 어느 섹션의 content에 포함되는지 판단.
Returns:
{"1. DX 시행을 위한 필수 요건": [1, 2, 3], "2. Process의 혁신과 Product의 변화": [4, 5]}
"""
section_topics: dict[str, list[int]] = {}
for sec in sections:
section_topics[sec["title"]] = []
for topic in topics:
tid = topic.get("id", 0)
t_title = topic.get("title", "").lower()
t_hint = topic.get("source_hint", "").lower()
best_section = None
best_score = 0
for sec in sections:
sec_content = sec["content"].lower()
sec_title = sec["title"].lower()
# sub_titles에서도 매칭
sub_titles_lower = " ".join(s.lower() for s in sec.get("sub_titles", []))
score = 0
# 꼭지 제목이 섹션 content에 포함되는지
key = t_title.split("(")[0].strip()
if key and len(key) >= 2:
if key in sec_content:
score += 10
if key in sec_title:
score += 5
if key in sub_titles_lower:
score += 8 # sub_title에 직접 매칭
# source_hint에 섹션 제목 키워드가 포함되는지
sec_key = sec_title.split(".")[-1].strip().lower()[:10]
if sec_key and len(sec_key) >= 2 and sec_key in t_hint:
score += 3
if score > best_score:
best_score = score
best_section = sec["title"]
if best_section and best_score > 0:
section_topics[best_section].append(tid)
else:
# 매칭 안 되면 첫 번째 섹션에 넣음
if sections:
section_topics[sections[0]["title"]].append(tid)
logger.warning(f"[section_parser] 꼭지 {tid} '{t_title}' 섹션 매핑 실패 → 첫 섹션")
# 빈 섹션 제거
section_topics = {k: v for k, v in section_topics.items() if v}
logger.info(
f"[section_parser] 꼭지-섹션 매핑: "
+ ", ".join(f'"{k}": {v}' for k, v in section_topics.items())
)
return section_topics

View File

@@ -34,11 +34,11 @@ _MEASURE_SCRIPT = """
containers: {}
};
// Zone 측정 (area-* 클래스)
var areaDivs = slide.querySelectorAll('[class*="area-"]');
// Zone 측정 (area-* 또는 zone-* 클래스)
var areaDivs = slide.querySelectorAll('[class*="area-"], [class*="zone-"]');
for (var i = 0; i < areaDivs.length; i++) {
var zone = areaDivs[i];
var areaMatch = zone.className.match(/area-(\\w+)/);
var areaMatch = zone.className.match(/(?:area|zone)-(\\w+)/);
if (!areaMatch) continue;
var areaName = areaMatch[1];

View File

@@ -468,9 +468,9 @@ def build_containers_type_b(
inner_w = slide_width - pad * 2
# 역할을 zone별로 분류
top_roles = [] # zone=top
bottom_roles = [] # zone=bottom_left, bottom_right
footer_role = None # zone=footer
top_roles = [] # zone=top
bottom_roles = [] # zone=bottom (전체폭) 또는 bottom_left/bottom_right (2분할)
footer_role = None # zone=footer (Phase Y: 결론은 slide-base가 처리, 여기서 무시)
for role_name, info in page_structure.items():
if not isinstance(info, dict):
@@ -478,32 +478,46 @@ def build_containers_type_b(
zone = info.get("zone", "")
if zone == "top":
top_roles.append((role_name, info))
elif zone in ("bottom_left", "bottom_right"):
elif zone in ("bottom", "bottom_left", "bottom_right"):
bottom_roles.append((role_name, info))
elif zone == "footer":
footer_role = (role_name, info)
# 전체 가용 높이: 슬라이드 - 패딩*2 - 헤더 - gap
total_available = slide_height - pad * 2 - header_h - gap_block
# Phase Y: slide-base.html 기준으로 가용 높이 계산
# slide-base: .slide-body = top:65px, height:590px
# 하단 footer pill = 41px (slide-base가 관리, 여기서 빼지 않음)
slide_body_top = 65 # slide-base .slide-body top
slide_body_h = 590 # slide-base .slide-body height
total_available = slide_body_h
# footer 높이: weight 비율 (최소 보장)
footer_weight = footer_role[1].get("weight", 0.1) if footer_role else 0.1
footer_h_raw = int(total_available * footer_weight)
_footer_min = int(14 * tokens.get("line_height_ko", 1.7) + pad)
footer_h = max(_footer_min, footer_h_raw)
# footer zone이 있으면 기존 방식으로 공간 배분 (하위 호환)
# footer zone이 없으면 (Phase Y) slide-base footer가 처리 → 전체를 zone에 사용
if footer_role:
footer_weight = footer_role[1].get("weight", 0.1)
footer_h_raw = int(total_available * footer_weight)
_footer_min = int(14 * tokens.get("line_height_ko", 1.7) + pad)
footer_h = max(_footer_min, footer_h_raw)
middle_h = total_available - footer_h - gap_block
else:
footer_h = 0
middle_h = total_available
# 중간 영역: footer + gap 제외
middle_h = total_available - footer_h - gap_block
# Phase Y: zone 제목 + gap 공간 확보
zone_count = len(top_roles) + len(bottom_roles)
zone_title_h = 28 # zone 제목 높이 (assembler와 동일)
zone_gap = 16 # zone 간 여백 (assembler와 동일)
zone_overhead = zone_count * zone_title_h + max(0, zone_count - 1) * zone_gap
usable_h = middle_h - zone_overhead
# 상단/하단 높이: weight 비율로
# 상단/하단 높이: weight 비율로 (usable 영역에서)
top_weight = sum(info.get("weight", 0) for _, info in top_roles)
bottom_weight = sum(info.get("weight", 0) for _, info in bottom_roles)
total_mid_weight = top_weight + bottom_weight
if total_mid_weight <= 0:
total_mid_weight = 1
top_h = int(middle_h * top_weight / total_mid_weight)
bottom_h = middle_h - top_h - gap_small # gap_small: 상단-하단 사이
top_h = int(usable_h * top_weight / total_mid_weight)
bottom_h = usable_h - top_h
# 상단: 이미지가 있으면 좌텍스트+우이미지 나란히 → 폭 분할
img_ratio = 0
@@ -541,16 +555,20 @@ def build_containers_type_b(
},
)
# 하단 역할: 2분할
bottom_col_w = (inner_w - gap_block) // 2
# 하단 역할: zone에 따라 전체폭 또는 2분할
has_bottom_full = any(info.get("zone") == "bottom" for _, info in bottom_roles)
bottom_col_w = inner_w if has_bottom_full else (inner_w - gap_block) // 2
for role_name, info in bottom_roles:
zone = info.get("zone", "bottom_left")
w = inner_w if zone == "bottom" else bottom_col_w
specs[role_name] = ContainerSpec(
role=role_name,
zone=info.get("zone", "bottom_left"),
zone=zone,
topic_ids=info.get("topic_ids", []),
weight=info.get("weight", 0),
height_px=bottom_h,
width_px=bottom_col_w,
width_px=w,
max_height_cost=_max_allowed_height_cost(bottom_h),
block_constraints={},
)

View File

@@ -29,8 +29,50 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
COLORS = {"배경": "#dc2626", "본심": "#2563eb", "첨부": "#16a34a", "결론": "#7c3aed"}
FONT_MAP = {"배경": "bg", "본심": "core", "첨부": "sidebar", "결론": "key_msg"}
COLORS_A = {"배경": "#dc2626", "본심": "#2563eb", "첨부": "#16a34a", "결론": "#7c3aed"}
FONT_MAP_A = {"배경": "bg", "본심": "core", "첨부": "sidebar", "결론": "key_msg"}
# Type B용 동적 색상 팔레트
_COLOR_PALETTE = ["#2563eb", "#16a34a", "#d97706", "#7c3aed", "#dc2626", "#0891b2"]
# 하위 호환: 기존 코드에서 COLORS/FONT_MAP 참조하는 곳 대응
COLORS = COLORS_A
FONT_MAP = FONT_MAP_A
def _is_type_b(ctx) -> bool:
"""page_structure에 zone 키가 있으면 Type B."""
ps = ctx.page_structure.roles if hasattr(ctx.page_structure, 'roles') else {}
for info in ps.values():
if isinstance(info, dict) and info.get("zone") in ("top", "bottom_left", "bottom_right"):
return True
return False
def _get_roles(ctx) -> list[str]:
"""page_structure의 실제 역할명 목록 (순서: zone 기준)."""
ps = ctx.page_structure.roles if hasattr(ctx.page_structure, 'roles') else {}
if _is_type_b(ctx):
zone_order = {"top": 0, "bottom_left": 1, "bottom_right": 2, "footer": 3}
roles = []
for role_name, info in ps.items():
if isinstance(info, dict):
z = info.get("zone", "")
roles.append((zone_order.get(z, 9), role_name))
return [r for _, r in sorted(roles)]
else:
return ["배경", "본심", "첨부", "결론"]
def _get_color(role: str, ctx=None) -> str:
"""역할명 → 색상. Type A는 고정, Type B는 동적."""
if role in COLORS_A:
return COLORS_A[role]
if ctx:
roles = _get_roles(ctx)
idx = roles.index(role) if role in roles else 0
return _COLOR_PALETTE[idx % len(_COLOR_PALETTE)]
return "#666666"
def generate_step_html(stage_name: str, ctx: "PipelineContext", steps_dir: Path) -> None:
@@ -74,21 +116,65 @@ def _tokens():
return _load_design_tokens()
def _calc_coords(containers: dict, ratio: tuple) -> dict:
def _calc_coords(containers: dict, ratio: tuple, ctx=None) -> dict:
"""역할별 좌표 계산. Type A/B 자동 분기."""
t = _tokens()
pad = t.get("spacing_page", 40)
gap = t.get("spacing_block", 20)
small = t.get("spacing_small", 8)
header_h = 66
inner_w = 1280 - pad * 2
body_w = int(inner_w * ratio[0] / 100) if ratio[0] > 0 else inner_w
sidebar_w = inner_w - body_w - gap if ratio[1] > 0 else 0
def gh(c):
if hasattr(c, "height_px"): return c.height_px
return c.get("height_px", 0) if isinstance(c, dict) else 0
def gw(c):
if hasattr(c, "width_px"): return c.width_px
return c.get("width_px", 0) if isinstance(c, dict) else 0
# Type B 감지
if ctx and _is_type_b(ctx):
ps = ctx.page_structure.roles
coords = {"header": {"l": pad, "t": pad, "w": inner_w, "h": header_h}}
# zone별 컨테이너 찾기
zone_map = {}
for role_name, info in ps.items():
if isinstance(info, dict):
zone_map[info.get("zone", "")] = role_name
top_role = zone_map.get("top", "")
bl_role = zone_map.get("bottom_left", "")
br_role = zone_map.get("bottom_right", "")
ft_role = zone_map.get("footer", "")
top_h = gh(containers.get(top_role, {}))
bl_h = gh(containers.get(bl_role, {}))
br_h = gh(containers.get(br_role, {}))
ft_h = gh(containers.get(ft_role, {}))
top_top = pad + header_h + gap
bottom_top = top_top + top_h + small
bottom_h = max(bl_h, br_h)
ft_top = bottom_top + bottom_h + gap
bottom_col_w = (inner_w - gap) // 2
if top_role:
coords[top_role] = {"l": pad, "t": top_top, "w": inner_w, "h": top_h}
if bl_role:
coords[bl_role] = {"l": pad, "t": bottom_top, "w": bottom_col_w, "h": bottom_h}
if br_role:
coords[br_role] = {"l": pad + bottom_col_w + gap, "t": bottom_top, "w": bottom_col_w, "h": bottom_h}
if ft_role:
coords[ft_role] = {"l": pad, "t": ft_top, "w": inner_w, "h": ft_h}
return coords
# Type A (기존)
body_w = int(inner_w * ratio[0] / 100) if ratio[0] > 0 else inner_w
sidebar_w = inner_w - body_w - gap if ratio[1] > 0 else 0
bg_h = gh(containers.get("배경", {}))
core_h = gh(containers.get("본심", {}))
sb_h = gh(containers.get("첨부", {}))
@@ -107,12 +193,38 @@ def _calc_coords(containers: dict, ratio: tuple) -> dict:
}
def _wrap(title, subtitle, slide_body):
return f"""<!DOCTYPE html><html><head><meta charset="UTF-8">
def _wrap(title, subtitle, slide_body, ctx=None):
"""slide-base.html 기반 래핑. step 시각화도 실제 슬라이드와 같은 기반 사용."""
from pathlib import Path
slide_base_path = Path(__file__).parent.parent / "templates" / "blocks" / "slide-base.html"
slide_title = ""
footer_text = ""
if ctx:
slide_title = ctx.analysis.title if ctx.analysis else ""
footer_text = ctx.analysis.conclusion_text if ctx.analysis else ""
try:
raw = slide_base_path.read_text(encoding="utf-8")
# {% block body %} → slide_body로 치환
raw = raw.replace("{% block body %}{% endblock %}", slide_body)
from jinja2 import Template
template = Template(raw)
slide_html = template.render(title=slide_title, footer_text=footer_text, footer_pill_bg="")
# step 라벨 추가
label = (f'<div style="position:fixed;top:4px;left:4px;z-index:999;font-size:14px;'
f'font-weight:bold;background:rgba(255,255,255,0.9);padding:4px 8px;border-radius:4px;">'
f'{title}</div>'
f'<div style="position:fixed;top:26px;left:4px;z-index:999;font-size:10px;'
f'color:#666;background:rgba(255,255,255,0.9);padding:2px 8px;border-radius:4px;">'
f'{subtitle}</div>')
return slide_html.replace('<body>', f'<body>{label}')
except Exception:
# fallback: 기존 방식
return f"""<!DOCTYPE html><html><head><meta charset="UTF-8">
<style>
*{{margin:0;padding:0;box-sizing:border-box;}}
body{{background:#e5e5e5;padding:10px;font-family:'Pretendard Variable','Noto Sans KR',sans-serif;word-break:keep-all;}}
.bl{{display:flex;gap:0;margin-bottom:2px;}}.bl-m{{flex-shrink:0;width:1em;}}.bl-t{{flex:1;}}
</style></head><body>
<div style="font-size:16px;font-weight:bold;margin-bottom:4px;">{title}</div>
<div style="font-size:11px;color:#666;margin-bottom:8px;">{subtitle}</div>
@@ -263,23 +375,39 @@ def _gen_stage_1b(ctx, steps_dir):
# ══════════════════════════════════════
def _gen_stage_1_5a(ctx, steps_dir):
coords = _calc_coords(ctx.containers, ctx.container_ratio)
fh = ctx.font_hierarchy
title = ctx.analysis.title or "슬라이드"
body = _hdr(coords["header"], title)
"""slide-base 위에 빈 zone 컨테이너만 표시."""
ps = ctx.page_structure.roles
gap = 8
for role in ["배경", "본심", "첨부", "결론"]:
c = coords[role]
cl = COLORS[role]
fk = FONT_MAP[role]
font = getattr(fh, fk, "?")
inner = (f'<div style="text-align:center;margin-top:{max(0,c["h"]//2-15)}px;">'
f'<b style="color:{cl};font-size:13px;">{role}</b><br>'
f'<span style="color:#888;font-size:10px;">{c["w"]}x{c["h"]}px / font:{font}px</span></div>')
body += _box(c, role, inner)
# zone 순서
zone_priority = {"top": 0, "bottom": 1, "bottom_left": 2, "bottom_right": 3}
roles_sorted = sorted(
[(r, info) for r, info in ps.items() if isinstance(info, dict)],
key=lambda x: zone_priority.get(x[1].get("zone", ""), 9),
)
r = ctx.container_ratio
html = _wrap(f"Step 1: 빈 컨테이너 (Stage 1.5a)", f"비율 {r[0]}:{r[1]}", body)
body_html = ""
for i, (role, info) in enumerate(roles_sorted):
ci = ctx.containers.get(role)
if not ci:
continue
cl = _get_color(role, ctx)
zone = info.get("zone", "")
w = ci.width_px
h = ci.height_px
tids = info.get("topic_ids", [])
body_html += (
f'<div style="width:{w}px;height:{h}px;border:2px dashed {cl};border-radius:6px;'
f'margin-bottom:{gap}px;display:flex;align-items:center;justify-content:center;">'
f'<div style="text-align:center;">'
f'<b style="color:{cl};font-size:14px;">{role}</b><br>'
f'<span style="color:#888;font-size:11px;">zone: {zone} / {w}×{h}px</span><br>'
f'<span style="color:#aaa;font-size:10px;">topics: {tids}</span>'
f'</div></div>\n'
)
html = _wrap("Stage 1.5a: 빈 컨테이너", "slide-base 위에 zone 배치", body_html, ctx=ctx)
(steps_dir / "stage_1_5a.html").write_text(html, encoding="utf-8")
@@ -288,19 +416,28 @@ def _gen_stage_1_5a(ctx, steps_dir):
# ══════════════════════════════════════
def _gen_stage_1_5a_content(ctx, steps_dir):
coords = _calc_coords(ctx.containers, ctx.container_ratio)
title = ctx.analysis.title or "슬라이드"
body = _hdr(coords["header"], title)
"""slide-base 위 zone에 topic 콘텐츠 배치."""
ps = ctx.page_structure.roles
topic_map = {t.id: t for t in ctx.topics}
gap = 8
for role in ["배경", "본심", "첨부", "결론"]:
c = coords[role]
cl = COLORS[role]
info = ps.get(role, {})
tids = info.get("topic_ids", []) if isinstance(info, dict) else []
zone_priority = {"top": 0, "bottom": 1, "bottom_left": 2, "bottom_right": 3}
roles_sorted = sorted(
[(r, info) for r, info in ps.items() if isinstance(info, dict)],
key=lambda x: zone_priority.get(x[1].get("zone", ""), 9),
)
lines = [f'<div style="font-size:10px;color:{cl};font-weight:700;margin-bottom:4px;">{role}</div>']
body_html = ""
for role, info in roles_sorted:
ci = ctx.containers.get(role)
if not ci:
continue
cl = _get_color(role, ctx)
tids = info.get("topic_ids", [])
w = ci.width_px
h = ci.height_px
lines = [f'<div style="font-size:10px;color:{cl};font-weight:700;margin-bottom:4px;">{role} ({w}×{h}px)</div>']
for tid in tids:
t = topic_map.get(tid)
if not t:
@@ -308,16 +445,18 @@ def _gen_stage_1_5a_content(ctx, steps_dir):
lines.append(f'<div style="font-size:11px;font-weight:700;margin-bottom:2px;">[꼭지{tid}] {t.title}{t.purpose} · {t.layer}</div>')
sd = t.source_data
if sd:
# 불릿으로 표시
for sent in sd.split(", "):
for sent in sd.split(", ")[:5]:
sent = sent.strip()
if sent:
lines.append(f'<div class="bl" style="font-size:10px;color:#444;"><span class="bl-m">•</span><span class="bl-t">{sent}</span></div>')
lines.append(f'<div style="font-size:10px;color:#444;padding-left:12px;">{sent}</div>')
inner = f'<div style="padding:6px 10px;overflow:hidden;">{"".join(lines)}</div>'
body += _box(c, role, inner)
body_html += (
f'<div style="width:{w}px;height:{h}px;border:2px solid {cl};border-radius:6px;'
f'margin-bottom:{gap}px;padding:6px 10px;overflow:hidden;">'
f'{"".join(lines)}</div>\n'
)
html = _wrap("Step 1b: 콘텐츠 배치 (꼭지 → 컨테이너)", "각 컨테이너에 배정된 꼭지의 source_data", body)
html = _wrap("Stage 1.5a: 콘텐츠 배치", "zone별 topic source_data", body_html, ctx=ctx)
(steps_dir / "stage_1_5a_content.html").write_text(html, encoding="utf-8")
@@ -326,36 +465,41 @@ def _gen_stage_1_5a_content(ctx, steps_dir):
# ══════════════════════════════════════
def _gen_stage_1_5b(ctx, steps_dir):
"""영역별 디자인 예산 (available height/width, fits 여부)."""
coords = _calc_coords(ctx.containers, ctx.container_ratio)
title = ctx.analysis.title or "슬라이드"
body = _hdr(coords["header"], title)
"""slide-base 위 zone별 디자인 예산."""
ps = ctx.page_structure.roles
gap = 8
zone_priority = {"top": 0, "bottom": 1, "bottom_left": 2, "bottom_right": 3}
roles_sorted = sorted(
[(r, info) for r, info in ps.items() if isinstance(info, dict)],
key=lambda x: zone_priority.get(x[1].get("zone", ""), 9),
)
for role in ["배경", "본심", "첨부", "결론"]:
c = coords[role]
cl = COLORS[role]
body_html = ""
for role, info in roles_sorted:
ci = ctx.containers.get(role)
if not ci:
continue
cl = _get_color(role, ctx)
w, h = ci.width_px, ci.height_px
db = ci.design_budget
if db and hasattr(db, 'model_dump'):
db = db.model_dump()
elif not isinstance(db, dict):
db = {}
avail_h = db.get("available_height_px", 0)
avail_w = db.get("available_width_px", 0)
fits = db.get("fits", False)
icon = "" if fits else "⚠️"
inner = (f'<div style="padding:6px 10px;">'
f'<div style="font-size:10px;color:{cl};font-weight:700;">{icon} {role} ({c["w"]}×{c["h"]}px)</div>'
f'<div style="font-size:10px;color:#555;">available: {avail_h}×{avail_w}px</div>'
f'<div style="font-size:10px;color:#555;">fits: {fits}</div>'
f'</div>')
body += _box(c, role, inner)
body_html += (
f'<div style="width:{w}px;height:{h}px;border:2px solid {cl};border-radius:6px;'
f'margin-bottom:{gap}px;padding:6px 10px;overflow:hidden;">'
f'<div style="font-size:11px;color:{cl};font-weight:700;">{icon} {role} ({w}×{h}px)</div>'
f'<div style="font-size:10px;color:#555;">available: {avail_h}×{avail_w}px / fits: {fits}</div>'
f'</div>\n'
)
html = _wrap("Stage 1.5b: 디자인 예산", "영역별 available_height/width + fits 여부", body)
html = _wrap("Stage 1.5b: 디자인 예산", "영역별 available_height/width + fits 여부", body_html, ctx=ctx)
(steps_dir / "stage_1_5b.html").write_text(html, encoding="utf-8")
@@ -364,30 +508,36 @@ def _gen_stage_1_5b(ctx, steps_dir):
# ══════════════════════════════════════
def _gen_stage_1_7(ctx, steps_dir):
coords = _calc_coords(ctx.containers, ctx.container_ratio)
title = ctx.analysis.title or "슬라이드"
body = _hdr(coords["header"], title)
"""slide-base 위 zone별 선택된 블록 표시."""
ps = ctx.page_structure.roles
gap = 8
zone_priority = {"top": 0, "bottom": 1, "bottom_left": 2, "bottom_right": 3}
roles_sorted = sorted(
[(r, info) for r, info in ps.items() if isinstance(info, dict)],
key=lambda x: zone_priority.get(x[1].get("zone", ""), 9),
)
for role in ["배경", "본심", "첨부", "결론"]:
c = coords[role]
cl = COLORS[role]
body_html = ""
for role, info in roles_sorted:
ci = ctx.containers.get(role)
if not ci:
continue
cl = _get_color(role, ctx)
w, h = ci.width_px, ci.height_px
ref_list = ctx.references.get(role, [])
lines = [f'<div style="font-size:10px;color:{cl};font-weight:700;margin-bottom:4px;">{role} ({c["w"]}x{c["h"]}px)</div>']
lines = [f'<div style="font-size:11px;color:{cl};font-weight:700;margin-bottom:4px;">{role} ({w}×{h}px)</div>']
for r in ref_list:
bid = r.block_id
var = r.variant
vtype = r.visual_type
line = f'<b>{bid}</b> ({var}) <span style="color:#888;font-size:9px;">{vtype}</span>'
# 주종 정보 — model_dump에서 확인
rd = r.model_dump() if hasattr(r, "model_dump") else {}
# BlockReference에는 supporting 정보가 없음 — stage_1_7_context.json에서 확인
lines.append(f'<div style="font-size:11px;margin-bottom:2px;">{line}</div>')
vtype_label = "tag_match ✅" if r.visual_type == "tag_match" else r.visual_type
lines.append(f'<div style="font-size:12px;margin-bottom:2px;"><b>{r.block_id}</b> ({r.variant}) — {vtype_label}</div>')
inner = f'<div style="padding:6px 10px;">{"".join(lines)}</div>'
body += _box(c, role, inner)
body_html += (
f'<div style="width:{w}px;height:{h}px;border:2px solid {cl};border-radius:6px;'
f'margin-bottom:{gap}px;padding:6px 10px;overflow:hidden;">'
f'{"".join(lines)}</div>\n'
)
html = _wrap("Step 2: 블록 선택 (Stage 1.7)", "layer 기반 주종 판단. 컨테이너 위에 블록 표시.", body)
html = _wrap("Stage 1.7: 블록 선택", "tag 매칭 기반 블록 선택", body_html, ctx=ctx)
(steps_dir / "stage_1_7.html").write_text(html, encoding="utf-8")
@@ -411,30 +561,35 @@ def _gen_stage_1_8_filled(ctx, steps_dir):
def _gen_stage_1_8_fit_before(ctx, steps_dir):
"""before: weight 비중대로 배정된 빈 컨테이너. fit 판단 없이 크기만 표시."""
coords = _calc_coords(ctx.containers, ctx.container_ratio)
title = ctx.analysis.title or "슬라이드"
body = _hdr(coords["header"], title)
for role in ["배경", "본심", "첨부", "결론"]:
c = coords[role]
cl = COLORS[role]
"""slide-base 위 zone별 초기 배정 (weight 기반)."""
ps = ctx.page_structure.roles
gap = 8
zone_priority = {"top": 0, "bottom": 1, "bottom_left": 2, "bottom_right": 3}
roles_sorted = sorted(
[(r, info) for r, info in ps.items() if isinstance(info, dict)],
key=lambda x: zone_priority.get(x[1].get("zone", ""), 9),
)
body_html = ""
for role, info in roles_sorted:
ci = ctx.containers.get(role)
if not ci:
continue
cl = _get_color(role, ctx)
w, h = ci.width_px, ci.height_px
weight = info.get("weight", 0)
ref_list = ctx.references.get(role, [])
blocks = ", ".join(r.block_id for r in ref_list) if ref_list else "미선택"
ps = ctx.page_structure.roles
info = ps.get(role, {})
weight = info.get("weight", 0) if isinstance(info, dict) else 0
body_html += (
f'<div style="width:{w}px;height:{h}px;border:2px dashed {cl};border-radius:6px;'
f'margin-bottom:{gap}px;padding:6px 10px;overflow:hidden;">'
f'<div style="font-size:11px;color:{cl};font-weight:700;">{role} ({w}×{h}px)</div>'
f'<div style="font-size:10px;color:#555;">weight: {weight} / 블록: {blocks}</div>'
f'</div>\n'
)
inner = (f'<div style="padding:6px 10px;">'
f'<div style="font-size:10px;color:{cl};font-weight:700;">{role} ({c["w"]}x{c["h"]}px)</div>'
f'<div style="font-size:10px;color:#555;">weight: {weight}</div>'
f'<div style="font-size:10px;color:#888;margin-top:2px;">블록: {blocks}</div>'
f'</div>')
body += _box(c, role, inner)
html = _wrap("Stage 1.8: before (weight 비중 초기 배정)", "빈 컨테이너. filled에서 텍스트를 채운 후 넘침 확인.", body)
html = _wrap("Stage 1.8: before", "weight 비중 초기 배정. 블록 채움 전.", body_html, ctx=ctx)
(steps_dir / "stage_1_8_fit_before.html").write_text(html, encoding="utf-8")
@@ -443,65 +598,48 @@ def _gen_stage_1_8_fit_before(ctx, steps_dir):
# ══════════════════════════════════════
def _gen_stage_1_8_fit_after(ctx, steps_dir):
fit = ctx.fit_result
enh = ctx.enhancement_result
"""slide-base 위 zone별 재배분 결과."""
ps = ctx.page_structure.roles
fit = ctx.fit_result or {}
enh = ctx.enhancement_result or {}
redist = fit.get("redistribution", {})
roles_fit = fit.get("roles", {})
gap = 8
# 재배분된 컨테이너
new_c = {}
for role, ci in ctx.containers.items():
new_h = int(redist.get(role, ci.height_px))
new_c[role] = {"height_px": new_h, "width_px": ci.width_px}
zone_priority = {"top": 0, "bottom": 1, "bottom_left": 2, "bottom_right": 3}
roles_sorted = sorted(
[(r, info) for r, info in ps.items() if isinstance(info, dict)],
key=lambda x: zone_priority.get(x[1].get("zone", ""), 9),
)
coords = _calc_coords(new_c, ctx.container_ratio)
title = ctx.analysis.title or "슬라이드"
body = _hdr(coords["header"], title)
emps = enh.get("emphasis_blocks", [])
bolds = enh.get("bold_keywords", {})
sups = enh.get("supplement_blocks", [])
for role in ["배경", "본심", "첨부", "결론"]:
c = coords[role]
cl = COLORS[role]
rf = roles_fit.get(role, {})
status = rf.get("fit_status", "?")
icon = {"OK": "", "TIGHT": "⚠️", "OVERFLOW": ""}.get(status, "?")
old_h = rf.get("allocated_px", 0)
body_html = ""
for role, info in roles_sorted:
ci = ctx.containers.get(role)
if not ci:
continue
cl = _get_color(role, ctx)
w = ci.width_px
old_h = ci.height_px
new_h = int(redist.get(role, old_h))
needed = rf.get("total_required_px", 0)
rf = roles_fit.get(role, {})
status = rf.get("fit_status", "OK")
icon = {"OK": "", "TIGHT": "⚠️", "OVERFLOW": ""}.get(status, "")
delta = new_h - old_h
delta_str = f" ({delta:+d}px)" if delta != 0 else ""
ref_list = ctx.references.get(role, [])
blocks = ", ".join(r.block_id for r in ref_list)
blocks = ", ".join(r.block_id for r in ref_list) if ref_list else ""
delta_str = f" <span style='color:#16a34a;'>({delta:+d}px)</span>" if delta != 0 else ""
body_html += (
f'<div style="width:{w}px;height:{new_h}px;border:2px solid {cl};border-radius:6px;'
f'margin-bottom:{gap}px;padding:6px 10px;overflow:hidden;">'
f'<div style="font-size:11px;color:{cl};font-weight:700;">{icon} {role} ({w}×{new_h}px){delta_str}</div>'
f'<div style="font-size:10px;color:#555;">블록: {blocks}</div>'
f'</div>\n'
)
inner = (f'<div style="padding:6px 10px;">'
f'<div style="font-size:10px;color:{cl};font-weight:700;">{icon} {role} ({c["w"]}x{new_h}px){delta_str}</div>'
f'<div style="font-size:10px;color:#555;">필요: {needed:.0f}px / 재배분 후: {new_h}px</div>'
f'<div style="font-size:10px;color:#888;margin-top:2px;">블록: {blocks}</div>')
# 보강 정보
role_emps = [e for e in emps if e.get("role") == role]
role_bolds = bolds.get(role, [])
role_sups = [s for s in sups if s.get("role") == role]
if role_emps:
for e in role_emps:
inner += f'<div style="margin-top:3px;background:#991b1b;color:#fff;border-radius:2px;padding:2px 6px;font-size:9px;font-weight:700;">강조: {e.get("sentence","")[:40]}...</div>'
if role_sups:
for s in role_sups:
inner += f'<div style="margin-top:3px;font-size:9px;color:#2563eb;">보충: {s.get("block_id")} ({s.get("content_source")})</div>'
if role_bolds:
inner += f'<div style="margin-top:3px;font-size:9px;color:#475569;">bold: {role_bolds[:4]}</div>'
inner += '</div>'
body += _box(c, role, inner)
redist_str = ", ".join(f"{r}:{int(v)}px" for r, v in redist.items())
html = _wrap("Step 3b: 재배분 후 + 보강 (Stage 1.8)", f"재배분: {redist_str}", body)
redist_str = ", ".join(f"{r}:{int(v)}px" for r, v in redist.items()) if redist else "재배분 없음"
html = _wrap("Stage 1.8: 재배분 후", f"재배분: {redist_str}", body_html, ctx=ctx)
(steps_dir / "stage_1_8_fit_after.html").write_text(html, encoding="utf-8")
@@ -510,161 +648,44 @@ def _gen_stage_1_8_fit_after(ctx, steps_dir):
# ══════════════════════════════════════
def _gen_stage_1_8_blocks(ctx, steps_dir):
"""재배분된 컨테이너에 블록 SLOT 구조 + 블록 디자인 + 주종관계 표시.
debug_steps/step2_phase_v.html 수준의 시각화."""
import re as _re
fit = ctx.fit_result or {}
redist = fit.get("redistribution", {})
topic_map = {t.id: t for t in ctx.topics}
ps = ctx.page_structure.roles
new_c = {}
for role, ci in ctx.containers.items():
new_h = int(redist.get(role, ci.height_px))
new_c[role] = {"height_px": new_h, "width_px": ci.width_px}
coords = _calc_coords(new_c, ctx.container_ratio)
title = ctx.analysis.title or "슬라이드"
all_block_css = set()
slide_body = _hdr(coords["header"], title)
legend_lines = []
for role in ["배경", "본심", "첨부", "결론"]:
c = coords[role]
cl = COLORS[role]
ref_list = ctx.references.get(role, [])
info = ps.get(role, {})
tids = info.get("topic_ids", []) if isinstance(info, dict) else []
if not ref_list:
slide_body += _box(c, role, f'<div style="padding:6px;color:#888;">블록 없음</div>')
continue
r0 = ref_list[0]
bid = r0.block_id
var = r0.variant
is_hier = r0.is_hierarchical if hasattr(r0, 'is_hierarchical') else False
sup_tids = r0.supporting_topic_ids if hasattr(r0, 'supporting_topic_ids') else []
primary_tid = r0.topic_id if hasattr(r0, 'topic_id') and r0.topic_id else (tids[0] if tids else None)
# 블록 디자인 HTML — SLOT 주석은 유지, 내용은 SLOT 마커로
raw = r0.design_reference_html or ""
# CSS 추출
styles = _re.findall(r'<style>(.*?)</style>', raw, _re.DOTALL)
for s in styles:
all_block_css.add(s)
clean = _re.sub(r'<style>.*?</style>', '', raw, flags=_re.DOTALL)
# SLOT 주석을 보이는 텍스트로 변환
def _slot_comment_to_visible(match):
text = match.group(1).strip()
if 'SLOT:' in text:
return f'<span style="color:#888;font-size:9px;background:#f0f0f0;padding:1px 4px;border-radius:2px;">{text}</span>'
return ''
clean = _re.sub(r'<!--\s*(SLOT:[^>]+?)-->', _slot_comment_to_visible, clean)
# 나머지 주석 제거
clean = _re.sub(r'<!--.*?-->', '', clean, flags=_re.DOTALL)
# 태그 라벨 (동적)
tag_parts = [f"{role} ({c['w']}×{c['h']})", bid]
if is_hier:
sup_str = "+".join(f"꼭지{st}" for st in sup_tids)
tag_parts.append(f"꼭지{primary_tid}(주)+{sup_str}(종) → 블록 1개")
tag_label = " · ".join(tag_parts)
# 종속 꼭지 SLOT 표시
sub_slot = ""
if is_hier and sup_tids:
for st in sup_tids:
st_topic = topic_map.get(st)
st_purpose = st_topic.purpose if st_topic and hasattr(st_topic, 'purpose') else ""
sub_slot += (
f'<div style="padding-left:8px;border-left:2px solid {cl};margin-top:4px;'
f'font-size:10px;color:{cl};">'
f'SLOT: 하위 (꼭지{st}{st_purpose})</div>'
)
# key-msg SLOT (본심만)
keymsg_slot = ""
if role == "본심" and ctx.analysis.core_message:
keymsg_slot = (
f'<div style="background:#dbeafe;border:1px solid #2563eb;border-radius:3px;'
f'padding:2px 6px;font-size:9px;color:#1e40af;font-weight:700;margin-top:4px;">'
f'SLOT: key-msg</div>'
)
inner = (
f'<div style="position:relative;padding-top:18px;width:100%;height:100%;">'
f'<span style="position:absolute;top:-16px;left:4px;font-size:9px;font-weight:700;'
f'background:white;padding:1px 6px;border-radius:3px;border:1px solid {cl};'
f'color:{cl};white-space:nowrap;">{tag_label}</span>'
f'{clean}{sub_slot}{keymsg_slot}</div>'
)
slide_body += (
f'<div style="position:absolute;left:{c["l"]}px;top:{c["t"]}px;width:{c["w"]}px;'
f'height:{c["h"]}px;border:2px dashed {cl};border-radius:6px;overflow:hidden;">'
f'{inner}</div>\n'
)
# 범례
if is_hier:
primary_topic = topic_map.get(primary_tid)
p_layer = primary_topic.layer if primary_topic and hasattr(primary_topic, 'layer') else ""
legend_lines.append(
f'{role}: 꼭지{primary_tid}({p_layer}) + '
f'{"+".join(f"꼭지{st}" for st in sup_tids)}'
f'<b>주종 관계 → {bid} 1개</b>'
)
else:
for r in ref_list:
t = topic_map.get(r.topic_id if hasattr(r, 'topic_id') else None)
t_layer = t.layer if t and hasattr(t, 'layer') else ""
legend_lines.append(f'{role}: 꼭지{r.topic_id}({t_layer}) → <b>{r.block_id}</b>')
css_block = "\n".join(all_block_css)
legend_html = "<br>".join(legend_lines)
html = f"""<!DOCTYPE html><html><head><meta charset="UTF-8">
<style>
*{{margin:0;padding:0;box-sizing:border-box;}}
body{{background:#e5e5e5;padding:10px;font-family:'Pretendard Variable','Noto Sans KR',sans-serif;word-break:keep-all;}}
:root{{--radius:6px;--line-height-ko:1.7;--color-accent:#2563eb;--color-primary:#1e293b;}}
{css_block}
</style></head><body>
<div style="font-size:16px;font-weight:bold;margin-bottom:4px;">Stage 1.8: SLOT 구조 + 블록 디자인 (재배분 후)</div>
<div style="font-size:11px;color:#666;margin-bottom:8px;">블록 디자인에 SLOT 마커 + 주종관계 표시. 다음 Stage 2에서 실제 콘텐츠로 채워짐.</div>
<div style="width:1280px;height:720px;background:white;position:relative;border:1px solid #ccc;">
{slide_body}
</div>
<div style="margin-top:16px;font-size:12px;color:#555;line-height:1.8;">
<b>블록 선택 근거 (layer 기반):</b><br>{legend_html}
</div></body></html>"""
"""slide-base 위에 실제 블록 렌더링. assemble_slide_html_final과 동일한 결과."""
from src.block_assembler import assemble_slide_html_final
html = assemble_slide_html_final(ctx)
(steps_dir / "stage_1_8_blocks.html").write_text(html, encoding="utf-8")
def _gen_stage_2(ctx, steps_dir):
"""Stage 2 결과: 영역별 Sonnet 출력을 실제 렌더링하여 보여줌.
각 역할(배경/본심/첨부/결론)의 HTML을 개별 컨테이너에 실제 렌더링."""
"""Stage 2 결과: 영역별 HTML 생성 결과.
Type A: Sonnet 영역별 출력. Type B: slide-base 완전 HTML."""
gen = ctx.generated_html or {}
sub_layouts = ctx.sub_layouts or {}
ps = ctx.page_structure.roles
# body_html에서 배경/본심 분리 (spacer로 구분)
# Type B: generated_html이 str (완전한 HTML)
if isinstance(gen, str):
html = f"""<!DOCTYPE html><html><head><meta charset="UTF-8">
<style>*{{margin:0;padding:0;box-sizing:border-box;}}
body{{background:#e5e5e5;padding:10px;font-family:'Pretendard Variable','Noto Sans KR',sans-serif;}}</style>
</head><body>
<div style="font-size:16px;font-weight:bold;margin-bottom:4px;">Stage 2: slide-base + 블록 템플릿 조립 결과 (Type B)</div>
<div style="font-size:11px;color:#666;margin-bottom:12px;">slide-base.html 배경 위에 블록 템플릿으로 조립된 최종 HTML</div>
<iframe srcdoc="{gen.replace('"', '&quot;')}" style="width:1280px;height:720px;border:1px solid #ccc;"></iframe>
</body></html>"""
(steps_dir / "stage_2.html").write_text(html, encoding="utf-8")
return
# Type A: dict (body_html, sidebar_html, footer_html)
import re as _re
body_html = gen.get("body_html", "")
sidebar_html = gen.get("sidebar_html", "")
footer_html = gen.get("footer_html", "")
# body_html = 배경 + spacer + 본심. spacer로 분리
import re as _re
spacer_pattern = r'<div style="height:\d+px;"></div>'
body_parts = _re.split(spacer_pattern, body_html, maxsplit=1)
bg_html = body_parts[0].strip() if len(body_parts) > 1 else ""
core_html = body_parts[1].strip() if len(body_parts) > 1 else body_html.strip()
# 역할별 HTML 매핑
role_htmls = {}
if bg_html and "배경" in ps:
role_htmls["배경"] = bg_html
@@ -680,11 +701,11 @@ def _gen_stage_2(ctx, steps_dir):
redist = fit.get("redistribution", {})
sections = []
for role in ["배경", "본심", "첨부", "결론"]:
for role in _get_roles(ctx):
rhtml = role_htmls.get(role, "")
if not rhtml:
continue
cl = COLORS.get(role, "#333")
cl = _get_color(role, ctx)
ci = ctx.containers.get(role)
if not ci:
continue
@@ -745,6 +766,51 @@ Stage 3 후처리: sidebar width:100% 조정, 폰트 캡핑 (배경≤{ctx.font_
# Stage 4: 품질 게이트
# ══════════════════════════════════════
def _gen_structure_validation(ctx) -> str:
"""sample-based 구조 검증. "어긋나면 안 된다" 기준."""
import re as _re
checks = []
html = ctx.rendered_html if hasattr(ctx, 'rendered_html') else ""
ps = ctx.page_structure.roles if hasattr(ctx.page_structure, 'roles') else {}
# 1. 본문 텍스트 visible (body에 실제 텍스트가 있는지)
body_start = html.find('<body') if html else -1
body_text = _re.sub(r'<[^>]+>', '', html[body_start:]) if body_start > 0 else ""
body_text = _re.sub(r'\s+', ' ', body_text).strip()
text_len = len(body_text)
ok = text_len > 100
checks.append(("본문 텍스트 visible", f"{'' if ok else ''} {text_len}"))
# 2. detail link 개수 (role당 1개)
link_count = len(_re.findall(r'자세히보기', html)) if html else 0
popup_count = len(ctx.normalized.popups) if hasattr(ctx.normalized, 'popups') else 0
ok = link_count <= max(popup_count, 1)
checks.append(("detail link 개수", f"{'' if ok else '⚠️'} {link_count}개 (popup {popup_count}개)"))
# 3. body 안 <style> 0개
body_styles = len(_re.findall(r'<style', html[body_start:])) if body_start > 0 else -1
ok = body_styles == 0
checks.append(("body 안 &lt;style&gt;", f"{'' if ok else ''} {body_styles}"))
# 4. conclusion * 없음
ct = ctx.analysis.conclusion_text if hasattr(ctx.analysis, 'conclusion_text') else ""
ok = not ct.startswith("*")
checks.append(("conclusion 선행 * 없음", f"{'' if ok else ''} \"{ct[:30]}\""))
# 5. sub_types 분류 확인
for rname, rinfo in ps.items():
if not isinstance(rinfo, dict):
continue
sub_types = rinfo.get("sub_types", [])
for st in sub_types:
checks.append((f"sub_type: {st.get('title','')[:20]}", st.get("sub_type", "미분류")))
rows = ""
for name, result in checks:
rows += f'<tr><td style="padding:6px 8px;">{name}</td><td style="padding:6px 8px;">{result}</td></tr>\n'
return rows
def _gen_stage_4(ctx, steps_dir):
"""Stage 4 결과: 측정값 + 품질 점수."""
measurement = ctx.measurement or {}
@@ -766,15 +832,76 @@ def _gen_stage_4(ctx, steps_dir):
f'<td style="padding:6px 8px;">{scroll_h}px</td>'
f'<td style="padding:6px 8px;">{excess:+d}px</td></tr>\n')
score_color = "#16a34a" if (isinstance(quality_score, (int, float)) and quality_score >= 80) else "#dc2626"
if isinstance(quality_score, (int, float)) and quality_score < 0:
score_color = "#d97706" # 미평가 = 주황
quality_score = "미평가 (비전 모델 미응답)"
elif isinstance(quality_score, (int, float)) and quality_score >= 80:
score_color = "#16a34a"
else:
score_color = "#dc2626"
# 블록/recipe 정보 (page_structure에서)
recipe_rows = ""
ps = ctx.page_structure.roles if hasattr(ctx.page_structure, 'roles') else {}
refs = ctx.references if hasattr(ctx, 'references') else {}
for rname, rinfo in ps.items():
if not isinstance(rinfo, dict):
continue
schema = rinfo.get("group_schema", "")
zone = rinfo.get("zone", "")
# block_id
role_refs = refs.get(rname, [])
block_id = ""
if role_refs:
r0 = role_refs[0]
block_id = r0.block_id if hasattr(r0, 'block_id') else r0.get("block_id", "")
is_recipe = block_id == "__needs_recipe__"
block_display = f"recipe ({schema})" if is_recipe else block_id
recipe_rows += (
f'<tr><td style="padding:6px 8px;">{zone}</td>'
f'<td style="padding:6px 8px;">{rname}</td>'
f'<td style="padding:6px 8px;">{schema}</td>'
f'<td style="padding:6px 8px;">{block_display}</td></tr>\n'
)
# popup 정보
popup_rows = ""
popups = ctx.normalized.popups if hasattr(ctx.normalized, 'popups') else []
for p in popups:
pid = p.popup_id if hasattr(p, 'popup_id') else ""
target = p.target_role if hasattr(p, 'target_role') else ""
pfile = p.popup_file if hasattr(p, 'popup_file') else ""
popup_rows += (
f'<tr><td style="padding:6px 8px;">{pid}</td>'
f'<td style="padding:6px 8px;">{target or "미연결"}</td>'
f'<td style="padding:6px 8px;">{pfile or "미확정"}</td></tr>\n'
)
html = f"""<!DOCTYPE html><html><head><meta charset="UTF-8">
<style>*{{margin:0;padding:0;box-sizing:border-box;}}body{{background:#e5e5e5;padding:10px;font-family:sans-serif;word-break:keep-all;}}</style>
<style>*{{margin:0;padding:0;box-sizing:border-box;}}body{{background:#e5e5e5;padding:10px;font-family:sans-serif;word-break:keep-all;}}
table{{border-collapse:collapse;font-size:12px;width:100%;max-width:700px;margin-top:8px;}}
th{{padding:8px;text-align:left;}}td{{padding:6px 8px;border-bottom:1px solid #ddd;}}</style>
</head><body>
<div style="font-size:16px;font-weight:bold;margin-bottom:8px;">Stage 4: 품질 게이트</div>
<div style="font-size:24px;font-weight:900;color:{score_color};margin-bottom:12px;">품질 점수: {quality_score}</div>
<div style="font-size:12px;color:#555;margin-bottom:4px;">슬라이드: clientHeight={slide_m.get("clientHeight", "?")}px, scrollHeight={slide_m.get("scrollHeight", "?")}px, overflow={slide_m.get("overflowed", "?")}</div>
<table style="border-collapse:collapse;font-size:12px;width:100%;max-width:600px;margin-top:8px;">
<tr style="background:#1e293b;color:white;"><th style="padding:8px;">영역</th><th style="padding:8px;">clientH</th><th style="padding:8px;">scrollH</th><th style="padding:8px;">excess</th></tr>{zone_rows}</table>
<div style="font-weight:700;margin-top:16px;margin-bottom:4px;">Overflow 측정</div>
<table>
<tr style="background:#1e293b;color:white;"><th>영역</th><th>clientH</th><th>scrollH</th><th>excess</th></tr>{zone_rows}</table>
<div style="font-weight:700;margin-top:16px;margin-bottom:4px;">블록/Recipe 선택</div>
<table>
<tr style="background:#1e293b;color:white;"><th>zone</th><th>role</th><th>schema</th><th>block/recipe</th></tr>{recipe_rows}</table>
<div style="font-weight:700;margin-top:16px;margin-bottom:4px;">Popup 연결</div>
<table>
<tr style="background:#1e293b;color:white;"><th>popup_id</th><th>target_role</th><th>popup_file</th></tr>{popup_rows if popup_rows else '<tr><td colspan="3">없음</td></tr>'}</table>
<div style="font-weight:700;margin-top:16px;margin-bottom:4px;">구조 검증</div>
<table>
<tr style="background:#1e293b;color:white;"><th>검증 항목</th><th>결과</th></tr>
{_gen_structure_validation(ctx)}
</table>
</body></html>"""
(steps_dir / "stage_4.html").write_text(html, encoding="utf-8")

View File

@@ -158,51 +158,8 @@ def validate_stage_1a(
})
return errors
# weight 합 검증 (0.9~1.1)
total_weight = sum(
info.get("weight", 0) for info in page_struct.values()
if isinstance(info, dict)
)
if total_weight < 0.9 or total_weight > 1.1:
errors.append({
"severity": "RETRYABLE",
"field": "page_structure.weight",
"localization": f"weight 합 {total_weight:.2f} (범위: 0.9~1.1)",
"instruction": f"weight 합이 1.0에 가깝도록 조정하라. 현재 합: {total_weight:.2f}",
})
# 유형에 따른 구조 검증
layout_template = analysis.get("layout_template", "A")
if layout_template == "A":
# 유형 A: 본심 필수
core_info = page_struct.get("본심", {})
if not core_info or not isinstance(core_info, dict):
errors.append({
"severity": "RETRYABLE",
"field": "page_structure.본심",
"localization": "본심 역할이 page_structure에 없음",
"instruction": "page_structure에 본심 역할을 추가하라. 본심은 슬라이드의 핵심 콘텐츠이다.",
})
elif core_info.get("weight", 0) < 0.3:
errors.append({
"severity": "RETRYABLE",
"field": "page_structure.본심.weight",
"localization": f"본심 weight {core_info['weight']:.2f} < 0.3",
"instruction": "본심은 슬라이드의 핵심. weight 0.3 이상 필요.",
})
elif layout_template == "B":
# 유형 B: 결론(footer) 필수, 나머지 자유
has_footer = any(
isinstance(info, dict) and info.get("zone") == "footer"
for info in page_struct.values()
)
if not has_footer and "결론" not in page_struct:
errors.append({
"severity": "RETRYABLE",
"field": "page_structure.footer",
"localization": "결론(footer) 역할이 없음",
"instruction": "유형 B에서도 결론 역할(zone: footer)은 필수이다.",
})
# Phase Y: page_structure 검증은 validate_page_structure()에서 별도 수행.
# Stage 1A에서는 Kei 응답의 page_structure를 검증하지 않음.
# 필수 필드 검증
for t in topics:
@@ -243,7 +200,8 @@ def validate_stage_1a(
# 원본 ## 섹션 수 vs topic 수 비교
original_sections = re.findall(r"^## .+$", clean_text, re.MULTILINE)
# 유형 B에서는 하나의 섹션을 여러 꼭지로 나눌 수 있으므로 허용 폭 확대
max_diff = 4 if layout_template == "B" else 2
_layout = analysis.get("layout_template", "A")
max_diff = 4 if _layout == "B" else 2
if len(original_sections) > 0 and abs(len(topics) - len(original_sections)) > max_diff:
errors.append({
"severity": "RETRYABLE",
@@ -336,18 +294,29 @@ def validate_stage_1b(
})
# ── 모순 탐지 (결정 테이블) ──
# Phase Y: Type B에서는 purpose/relation_type이 블록 선택의 핵심 입력이 아님
# (tag 매칭이 item_count + content_example로 동작)
# → Type B: 경고만 (파이프라인 계속). Type A: hard fail 유지.
if purpose in CONTRADICTIONS:
if relation_type in CONTRADICTIONS[purpose]:
errors.append({
"severity": "RETRYABLE",
"field": f"topics[{tid}].relation_type",
"localization": f"topic {tid}: purpose '{purpose}' × relation_type '{relation_type}' 모순",
"current_value": f"purpose={purpose}, relation_type={relation_type}",
"evidence": f"'{purpose}''{relation_type}'와 논리적으로 양립 불가",
"instruction": f"relation_type을 재판단하라. '{purpose}'에 적합한 관계는 "
f"{[r for r in VALID_RELATION_TYPES if r not in CONTRADICTIONS.get(purpose, [])]}",
})
if layout_template == "B":
# Type B: 경고만
logger.warning(
f"[T-2 모순경고] topic {tid}: purpose '{purpose}' × relation_type '{relation_type}' "
f"— Type B에서는 보조 힌트이므로 경고만"
)
else:
# Type A: hard fail 유지
errors.append({
"severity": "RETRYABLE",
"field": f"topics[{tid}].relation_type",
"localization": f"topic {tid}: purpose '{purpose}' × relation_type '{relation_type}' 모순",
"current_value": f"purpose={purpose}, relation_type={relation_type}",
"evidence": f"'{purpose}''{relation_type}'와 논리적으로 양립 불가",
"instruction": f"relation_type을 재판단하라. '{purpose}'에 적합한 관계는 "
f"{[r for r in VALID_RELATION_TYPES if r not in CONTRADICTIONS.get(purpose, [])]}",
})
if purpose in SOFT_WARNINGS:
if relation_type in SOFT_WARNINGS[purpose]:
@@ -400,3 +369,35 @@ def validate_stage_1b(
})
return errors
def validate_page_structure(page_struct: dict) -> list[dict]:
"""Phase Y: section_parser가 생성한 page_structure 검증.
Stage 1A 후, section_parser + 블록 매칭으로 page_structure가 채워진 후 호출.
"""
errors = []
if not page_struct:
errors.append({
"severity": "FATAL",
"field": "page_structure",
"localization": "page_structure가 비어있음",
"instruction": "section_parser가 영역을 생성하지 못함",
})
return errors
# weight 합 검증 (0.9~1.1)
total_weight = sum(
info.get("weight", 0) for info in page_struct.values()
if isinstance(info, dict)
)
if total_weight < 0.9 or total_weight > 1.1:
errors.append({
"severity": "RETRYABLE",
"field": "page_structure.weight",
"localization": f"weight 합 {total_weight:.2f} (범위: 0.9~1.1)",
"instruction": f"weight 합이 1.0에 가깝도록 조정하라. 현재 합: {total_weight:.2f}",
})
return errors