Add Type B slide pipeline and recipe rendering updates
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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', '')}, "
|
||||
|
||||
348
src/pipeline.py
348
src/pipeline.py
@@ -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">
|
||||
|
||||
@@ -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 직렬화
|
||||
|
||||
|
||||
@@ -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
573
src/section_parser.py
Normal 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
|
||||
@@ -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];
|
||||
|
||||
|
||||
@@ -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={},
|
||||
)
|
||||
|
||||
@@ -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('"', '"')}" 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 안 <style>", 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")
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user