Phase W + V' 완료: before→filled→after 파이프라인 + 조립 로직 수정

Phase W:
- weight 비율 초기 배정 (space_allocator header 높이 반영)
- block_assembler 공통 조립 함수 (filled/assembled 통합)
- filled → Selenium 측정 → context 저장
- sidebar overflow 확장 + body 재배분
- sub_layouts 사전 계산 (이미지 누락 해결)

Phase V':
- 팝업 링크 우측상단 배치 (인라인 → position:absolute)
- 표 내용 Kei 판단 (공란 크기 계산 → 행/열 산출 → Kei 요약)
- 출처 라벨 삭제 + 이미지 아래 캡션 배치
- after 공란 제거 (결론 바로 위까지 body/sidebar 채움)

추가:
- V-10 bold 키워드: 기계적 추출 → Kei 문맥 판단
- ** 마크다운 → <strong> 변환
- [이미지:] 마커 제거 (bold 변환 전 처리)
- grid-template-rows AFTER 크기 반영 (Sonnet final)
- assemble_stage2 CSS font-size override, white-space fix
- 하드코딩 전수 검토 완료
- 본심 여러 topic 텍스트 합침

Phase X 계획 문서 작성 (동적 역할 구조)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-06 05:00:52 +09:00
parent 24eb1bc5ad
commit 1f7579cf64
64 changed files with 13955 additions and 696 deletions

445
src/block_assembler.py Normal file
View File

@@ -0,0 +1,445 @@
"""블록 조립 공통 모듈.
filled, assembled, Stage 2 모두 이 모듈의 함수를 사용.
조립 로직이 한 곳에만 존재하여 수정 사항이 전체에 반영됨.
입력: PipelineContext (또는 동등한 dict)
출력: 역할별 HTML dict + 슬라이드 전체 HTML
하드코딩 없음. font_hierarchy, sub_layouts, design_reference_html, structured_text에서 동적으로.
"""
from __future__ import annotations
import re
import logging
from typing import Any, TYPE_CHECKING
if TYPE_CHECKING:
from src.pipeline_context import PipelineContext
logger = logging.getLogger(__name__)
COLORS = {"배경": "#dc2626", "본심": "#2563eb", "첨부": "#16a34a", "결론": "#7c3aed"}
FONT_MAP = {"배경": "bg", "본심": "core", "첨부": "sidebar", "결론": "key_msg"}
def assemble_role_html(
role: str,
ctx: "PipelineContext",
) -> tuple[str, set[str]]:
"""하나의 역할(배경/본심/첨부/결론)에 대해 블록 디자인 + 텍스트를 조립.
Returns:
(조립된 HTML, 사용된 CSS set)
"""
ps = ctx.page_structure.roles
info = ps.get(role, {})
if not isinstance(info, dict):
return "", set()
tids = info.get("topic_ids", [])
if not tids:
return "", set()
topic_map = {t.id: t for t in ctx.topics}
ref_list = ctx.references.get(role, [])
if not ref_list:
return "", set()
r0 = ref_list[0]
primary_tid = r0.topic_id if hasattr(r0, 'topic_id') and r0.topic_id else (tids[0] if tids else None)
primary_topic = topic_map.get(primary_tid)
if not primary_topic:
return "", set()
font_key = FONT_MAP.get(role, "core")
font_size = getattr(ctx.font_hierarchy, font_key, 12)
sub_layouts = ctx.sub_layouts or {}
role_sub = sub_layouts.get(role, {})
role_scs = role_sub.get("sub_containers", [])
# #10: V-10 bold 키워드
enh = ctx.enhancement_result or {}
bold_kw = enh.get("bold_keywords", {}) if isinstance(enh.get("bold_keywords"), dict) else {}
role_bold = bold_kw.get(role, [])
# ── 블록 디자인 HTML에서 CSS 추출 ──
ref_html = r0.design_reference_html or ""
css_parts = re.findall(r'<style>(.*?)</style>', ref_html, re.DOTALL)
block_body = re.sub(r'<style>.*?</style>', '', ref_html, flags=re.DOTALL)
block_body = re.sub(r'<!--.*?-->', '', block_body, flags=re.DOTALL).strip()
# CSS font-size override (font_hierarchy 기준)
overridden_css = set()
for css in css_parts:
def _override_font(m):
val = float(m.group(1))
if val > font_size + 2:
return f"font-size: {font_size + 1}px"
elif val > font_size:
return f"font-size: {font_size}px"
return m.group(0)
oc = re.sub(r'font-size:\s*(\d+(?:\.\d+)?)px', _override_font, css)
# gap, padding, number size도 font_size 비례
oc = re.sub(r'gap:\s*\d+px', f'gap: {max(3, int(font_size * 0.4))}px', oc)
oc = re.sub(r'width:\s*32px;\s*\n\s*height:\s*32px',
f'width: {int(font_size * 2)}px;\n height: {int(font_size * 2)}px', oc)
oc = re.sub(r'padding:\s*12px\s+16px', f'padding: {int(font_size*0.7)}px {int(font_size)}px', oc)
oc = oc.replace('white-space: pre-line', 'white-space: normal')
overridden_css.add(oc)
# ── structured_text 파싱 (들여쓰기 보존) ──
st = primary_topic.structured_text or primary_topic.source_data or ""
st_lines, popup_titles = _parse_structured_text(st, font_size)
# ── sub_layouts 기반 판단 ──
has_svg = any(sc.get("name") == "svg" for sc in role_scs)
has_keymsg = any(sc.get("name") == "keymsg" for sc in role_scs)
# #11: V-9 강조 블록
emphasis_blocks = enh.get("emphasis_blocks", [])
role_emphasis = ""
for eb in emphasis_blocks:
if eb.get("role") == role:
role_emphasis = eb.get("sentence", "")
break
# #12: V-7 종속꼭지 텍스트
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 []
sub_topics_text = []
if is_hier and sup_tids:
for st_id in sup_tids:
st_topic = topic_map.get(st_id)
if st_topic:
st_text = st_topic.structured_text or st_topic.source_data or ""
sub_topics_text.append(st_text[:120])
# ── 블록 구조별 조립 ──
if "block-callout-warn" in block_body or "block-callout-sol" in block_body:
inner = _assemble_callout(block_body, primary_topic, st_lines, font_size, role_bold, role_emphasis, sub_topics_text)
elif "block-card-num" in block_body:
inner = _assemble_card_numbered(primary_topic, st_lines, font_size, role_scs, role_bold)
elif "block-banner-grad" in block_body:
inner = _assemble_banner(block_body, ctx.analysis.core_message or primary_topic.title)
elif has_svg:
# 실제 이미지 파일이 있는 경우만 SVG 레이아웃 사용
# slide_images에 실제 이미지가 있는지 확인
has_real_image = any(
img.get("b64") or img.get("path", "").strip()
for img in (ctx.slide_images or [])
)
if has_real_image:
inner = _assemble_svg_layout(block_body, primary_topic, st_lines, font_size, role_scs, ctx.analysis.core_message, has_keymsg, ctx.slide_images, bold_keywords=role_bold)
else:
inner = _assemble_generic(primary_topic, st_lines, font_size, has_keymsg, ctx.analysis.core_message, role_scs, bold_keywords=role_bold)
else:
inner = _assemble_generic(primary_topic, st_lines, font_size, has_keymsg, ctx.analysis.core_message, role_scs, bold_keywords=role_bold)
# V'-1: 팝업 링크를 컨테이너 우측상단에 배치
popup_html = _popup_links_html(popup_titles, font_size)
if popup_html:
inner = f'<div style="position:relative;">{popup_html}{inner}</div>'
return inner, overridden_css
def _parse_structured_text(st: str, font_size: float) -> tuple[list[tuple[int, str]], list[str]]:
"""structured_text → ([(indent, text)], [팝업 제목 리스트]).
[팝업:]은 텍스트에서 분리하여 별도 리스트로 반환. [이미지:]는 제거. **bold** → <strong>."""
lines = []
popup_titles = []
for raw_line in st.split("\n"):
stripped = raw_line.strip()
if not stripped:
continue
indent = 1 if raw_line.startswith(" ") else 0
# 마커 처리 (bold 변환 전)
popup_match = re.search(r'\[팝업:\s*([^\]]+)\]', stripped)
if popup_match:
popup_titles.append(popup_match.group(1))
continue
if re.search(r'\[이미지:', stripped):
continue
# 마크다운 bold → HTML (마커 처리 후)
stripped = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', stripped)
lines.append((indent, stripped))
return lines, popup_titles
def _apply_bold(text: str, keywords: list[str]) -> str:
"""V-10 bold 키워드를 <strong>으로 감쌈."""
for kw in keywords:
if kw in text:
text = text.replace(kw, f"<strong>{kw}</strong>")
return text
def _popup_links_html(popup_titles: list[str], font_size: float) -> str:
"""팝업 제목 리스트 → 우측상단 배치용 HTML."""
if not popup_titles:
return ""
links = " ".join(
f'<span style="color:#2563eb;font-size:{font_size - 2}px;cursor:pointer;">[{t}→]</span>'
for t in popup_titles
)
return (
f'<div style="position:absolute;top:4px;right:8px;text-align:right;z-index:1;">'
f'{links}</div>'
)
def _st_lines_to_bullets(st_lines: list[tuple[int, str]], font_size: float, bold_keywords: list[str] | None = None) -> str:
"""(indent, text) 리스트를 HTML 불릿으로."""
bk = bold_keywords or []
html = ""
for indent, text in st_lines:
clean = _apply_bold(text.lstrip(""), bk)
if text.startswith("출처:") or clean.startswith("출처:"):
# V'-3: "출처:" 라벨 삭제, 텍스트만 표시
caption = re.sub(r'^출처:\s*', '', clean)
html += f'<div style="font-size:{font_size-2}px;color:#94a3b8;">{caption}</div>\n'
elif indent == 1:
html += f'<div class="bl" style="padding-left:1em;font-size:{font_size}px;"><span class="bl-m">•</span><span class="bl-t">{clean}</span></div>\n'
else:
html += f'<div class="bl" style="font-size:{font_size}px;"><span class="bl-m">•</span><span class="bl-t">{clean}</span></div>\n'
return html
def _assemble_callout(block_body, topic, st_lines, font_size, bold_keywords=None, emphasis="", sub_topics_text=None):
"""callout-warning/solution 블록에 텍스트 채움."""
bk = bold_keywords or []
desc_html = _st_lines_to_bullets(st_lines, font_size, bold_keywords=bk)
# V-7 종속꼭지 인라인
sub_html = ""
for st_text in (sub_topics_text or []):
sub_html += (
f'<div style="padding-left:1em;margin-top:2px;color:#9b1c1c;font-size:{font_size-1}px;'
f'border-left:2px solid #fca5a5;">{_apply_bold(st_text, bk)}</div>'
)
# V-9 강조 블록
emph_html = ""
if emphasis:
emph_html = (
f'<div style="background:#991b1b;color:#fff;border-radius:3px;'
f'padding:3px 8px;font-size:{font_size-1}px;font-weight:700;margin-top:2px;">'
f'{_apply_bold(emphasis, bk)}</div>'
)
inner = re.sub(r'<div class="cw-title">.*?</div>',
f'<div class="cw-title">{_apply_bold(topic.title, bk)}</div>', block_body, flags=re.DOTALL)
inner = re.sub(r'<div class="cw-desc">.*?</div>',
f'<div class="cw-desc" style="font-size:{font_size}px;">{desc_html}{sub_html}{emph_html}</div>', inner, flags=re.DOTALL)
return inner
def _assemble_card_numbered(topic, st_lines, font_size, role_scs, bold_keywords=None):
"""card-numbered 블록에 카드별 텍스트 채움."""
# indent=0 주불릿 = 카드 제목, indent=1 = 카드 설명
cards = []
current_title = ""
current_descs = []
for indent, text in st_lines:
clean = text.lstrip("")
if indent == 0 and text.startswith(""):
if current_title:
cards.append((current_title, current_descs))
current_title = clean
current_descs = []
else:
current_descs.append(clean)
if current_title:
cards.append((current_title, current_descs))
# sidebar 라벨
label = f'<div style="font-size:{font_size-1}px;color:#64748b;font-weight:700;margin-bottom:4px;">{topic.title}</div>'
bk = bold_keywords or []
card_gap = max(3, int(font_size * 0.4))
items_html = ""
for i, (title, descs) in enumerate(cards):
desc_html = ""
for d in descs:
d = _apply_bold(d, bk)
if d.startswith("출처:"):
caption = re.sub(r'^출처:\s*', '', d)
desc_html += f'<div style="font-size:{font_size-2}px;color:#94a3b8;">{caption}</div>\n'
else:
desc_html += f'<div class="bl"><span class="bl-m">•</span><span class="bl-t">{d}</span></div>\n'
num_size = int(font_size * 2)
items_html += (
f'<div class="cn-item">'
f'<div class="cn-number" style="background:#2563eb;width:{num_size}px;height:{num_size}px;font-size:{font_size-1}px;">{i+1}</div>'
f'<div class="cn-body">'
f'<div class="cn-title" style="font-size:{font_size}px;">{_apply_bold(title, bk)}</div>'
f'<div class="cn-desc" style="font-size:{font_size-1}px;white-space:normal;">{desc_html}</div>'
f'</div></div>\n'
)
return f'{label}<div class="block-card-num" style="gap:{card_gap}px;">{items_html}</div>'
def _assemble_banner(block_body, message):
"""banner-gradient 블록에 메시지 채움."""
inner = re.sub(r'<div class="bg-text">.*?</div>',
f'<div class="bg-text">{message}</div>', block_body, flags=re.DOTALL)
inner = re.sub(r'<div class="bg-sub">.*?</div>', '', inner, flags=re.DOTALL)
return inner
def _assemble_svg_layout(block_body, topic, st_lines, font_size, role_scs, core_message, has_keymsg, slide_images=None, bold_keywords=None):
"""이미지(좌) + 텍스트(우) + key-msg(하단) 레이아웃. 실제 이미지 파일 사용."""
# 실제 이미지가 있으면 <img> 사용, 없으면 빈 placeholder
img_html = ""
if slide_images:
for img in slide_images:
b64 = img.get("b64", "")
if b64:
img_html = f'<img src="data:image/png;base64,{b64}" style="width:100%;height:100%;object-fit:contain;" />'
break
svg_sc = next((sc for sc in role_scs if sc["name"] == "svg"), None)
text_sc = next((sc for sc in role_scs if sc["name"] == "text_and_table"), None)
svg_w = int(svg_sc["width_px"]) if svg_sc else 200
svg_h = int(svg_sc["height_px"]) if svg_sc else 265
# 출처 라인을 이미지 아래 캡션으로 분리
caption_lines = []
content_lines = []
for indent, text in st_lines:
clean = text.lstrip("")
if text.startswith("출처:") or clean.startswith("출처:"):
caption_lines.append(re.sub(r'^출처:\s*', '', clean))
else:
content_lines.append((indent, text))
img_caption = ""
if caption_lines:
img_caption = f'<div style="font-size:{font_size-2}px;color:#94a3b8;text-align:center;margin-top:2px;">{caption_lines[0]}</div>'
bullets = _st_lines_to_bullets(content_lines, font_size, bold_keywords=bold_keywords)
bk = bold_keywords or []
keymsg_html = ""
if has_keymsg and core_message:
keymsg_sc = next((sc for sc in role_scs if sc["name"] == "keymsg"), None)
km_h = int(keymsg_sc["height_px"]) if keymsg_sc else 37
keymsg_html = (
f'<div style="background:#dbeafe;border:1px solid #2563eb;border-radius:4px;'
f'padding:4px 8px;font-size:{font_size+2}px;font-weight:700;color:#1e40af;'
f'text-align:center;height:{km_h}px;display:flex;align-items:center;'
f'justify-content:center;flex-shrink:0;">{_apply_bold(core_message, bk)}</div>'
)
return (
f'<div style="display:flex;flex-direction:column;height:100%;padding:8px;box-sizing:border-box;">'
f'<div style="font-weight:700;font-size:{font_size+1}px;color:#1a365d;margin-bottom:4px;">'
f'{_apply_bold(topic.title, bk)}</div>'
f'<div style="display:flex;gap:{max(6, int(font_size * 0.8))}px;flex:1;min-height:0;align-items:flex-start;">'
f'<div style="width:{svg_w}px;flex-shrink:0;"><div style="height:{svg_h}px;border-radius:6px;overflow:hidden;">{img_html}</div>{img_caption}</div>'
f'<div style="flex:1;font-size:{font_size}px;line-height:1.55;color:#333;">{bullets}</div>'
f'</div>{keymsg_html}</div>'
)
def _assemble_generic(topic, st_lines, font_size, has_keymsg, core_message, role_scs, bold_keywords=None):
"""기타 블록: 제목 + 불릿."""
bk = bold_keywords or []
bullets = _st_lines_to_bullets(st_lines, font_size, bold_keywords=bk)
keymsg_html = ""
if has_keymsg and core_message:
keymsg_sc = next((sc for sc in role_scs if sc["name"] == "keymsg"), None)
km_h = int(keymsg_sc["height_px"]) if keymsg_sc else 37
keymsg_html = (
f'<div style="background:#dbeafe;border:1px solid #2563eb;border-radius:4px;'
f'padding:4px 8px;font-size:{font_size+2}px;font-weight:700;color:#1e40af;'
f'text-align:center;height:{km_h}px;display:flex;align-items:center;'
f'justify-content:center;flex-shrink:0;">{_apply_bold(core_message, bk)}</div>'
)
return (
f'<div style="height:100%;padding:6px;font-size:{font_size}px;line-height:1.4;">'
f'<div style="font-weight:700;font-size:{font_size+1}px;margin-bottom:4px;">{_apply_bold(topic.title, bk)}</div>'
f'{bullets}{keymsg_html}</div>'
)
def assemble_slide_html(ctx: "PipelineContext", title_text: str = "") -> str:
"""전체 슬라이드를 조립하여 HTML 반환.
filled, assembled, stage_2 모두 이 함수를 호출.
"""
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"]
ratio = ctx.container_ratio
slide_w = tokens.get("slide_width", 1280)
slide_h = tokens.get("slide_height", 720)
inner_w = slide_w - pad * 2
body_w = int(inner_w * ratio[0] / 100)
sidebar_w = inner_w - body_w - gap_block
fit = ctx.fit_result or {}
redist = fit.get("redistribution", {})
all_css = set()
role_htmls = {}
for role in ["배경", "본심", "첨부", "결론"]:
html, css = assemble_role_html(role, ctx)
role_htmls[role] = html
all_css.update(css)
# 좌표 계산
bg_h = int(redist.get("배경", ctx.containers.get("배경", type("", (), {"height_px": 0})).height_px))
core_h = int(redist.get("본심", ctx.containers.get("본심", type("", (), {"height_px": 0})).height_px))
sb_h = int(redist.get("첨부", ctx.containers.get("첨부", type("", (), {"height_px": 0})).height_px))
concl_h = int(redist.get("결론", ctx.containers.get("결론", type("", (), {"height_px": 0})).height_px))
bg_top = pad + header_h + gap_block
core_top = bg_top + bg_h + gap_small
sb_top = bg_top
# V'-4: after(redistribution 있을 때)에서 결론 바로 위까지 body/sidebar 채움
if redist:
ft_top = slide_h - pad - concl_h - gap_block
column_bottom = ft_top - gap_block
core_h = column_bottom - core_top
sb_h = column_bottom - sb_top
else:
ft_top = max(core_top + core_h, bg_top + sb_h) + gap_block
title = title_text or ctx.analysis.title or ""
css_block = "\n".join(all_css)
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;}}
:root{{--radius:6px;--line-height-ko:1.7;--color-accent:#2563eb;--color-primary:#1e293b;}}
.bl{{display:flex;gap:0;margin-bottom:2px;}}.bl-m{{flex-shrink:0;width:1em;}}.bl-t{{flex:1;}}
{css_block}
</style></head><body>
<div class="slide" style="width:{slide_w}px;height:{slide_h}px;background:white;position:relative;border:1px solid #ccc;">
<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>
<div class="area-body" style="position:absolute;left:{pad}px;top:{bg_top}px;width:{body_w}px;height:{bg_h}px;border:2px solid #dc2626;border-radius:6px;overflow:hidden;">
<span style="position:absolute;top:2px;left:4px;font-size:7px;color:#dc2626;opacity:0.5;">배경 ({body_w}x{bg_h}px)</span>
{role_htmls.get("배경", "")}</div>
<div class="area-body" style="position:absolute;left:{pad}px;top:{core_top}px;width:{body_w}px;height:{core_h}px;border:2px solid #2563eb;border-radius:6px;overflow:hidden;">
<span style="position:absolute;top:2px;left:4px;font-size:7px;color:#2563eb;opacity:0.5;">본심 ({body_w}x{core_h}px)</span>
{role_htmls.get("본심", "")}</div>
<div class="area-sidebar" style="position:absolute;left:{pad + body_w + gap_block}px;top:{sb_top}px;width:{sidebar_w}px;height:{sb_h}px;border:2px solid #16a34a;border-radius:6px;overflow:hidden;">
<span style="position:absolute;top:2px;left:4px;font-size:7px;color:#16a34a;opacity:0.5;">첨부 ({sidebar_w}x{sb_h}px)</span>
{role_htmls.get("첨부", "")}</div>
<div class="area-footer" style="position:absolute;left:{pad}px;top:{ft_top}px;width:{inner_w}px;height:{concl_h}px;border:2px solid #7c3aed;border-radius:8px;overflow:hidden;">
<span style="position:absolute;top:2px;left:4px;font-size:7px;color:#7c3aed;opacity:0.5;">결론 ({inner_w}x{concl_h}px)</span>
{role_htmls.get("결론", "")}</div>
</div></body></html>"""

557
src/block_reference.py Normal file
View File

@@ -0,0 +1,557 @@
"""Phase T-3: 참고 블록 선택 + 디자인 레퍼런스 HTML 생성.
Stage 1.7에서 호출. relation_type + expression_hint → 참고 블록 결정론적 선택.
블록을 "채울 틀"이 아니라 "참고할 디자인"으로 제공.
핵심 차이 (Phase P~R vs Phase T):
P~R: 블록 선택 → 슬롯에 텍스트 채우기 (실패 — 구조 경직)
T: 블록을 참고 자료로 제공 → AI가 구조를 자유롭게 결정 (유연 + 다양)
설계 근거:
- expression_hint 키워드 포함 매칭 (정확한 문자열 아님 — T-3 조사)
- LLM이 참고 HTML 구조를 70-90% 복사 (T-3 조사) → "디자인 레퍼런스" 프레이밍
- Gestalt 원칙: 폐합→벤, 근접→좌우, 연속→화살표 (T-3 조사)
- PPTAgent(EMNLP 2025): 참고 기반 생성의 효과 학술 입증
"""
from __future__ import annotations
import logging
import re
from pathlib import Path
from typing import Any
import yaml
from jinja2 import Environment, FileSystemLoader
logger = logging.getLogger(__name__)
# 템플릿 디렉토리
TEMPLATES_DIR = Path(__file__).parent.parent / "templates"
# Jinja2 환경 (블록 HTML 렌더링용)
_jinja_env = None
def _get_jinja_env() -> Environment:
global _jinja_env
if _jinja_env is None:
_jinja_env = Environment(
loader=FileSystemLoader(str(TEMPLATES_DIR)),
autoescape=False,
)
return _jinja_env
# ══════════════════════════════════════
# expression_hint → 블록 매핑 (키워드 포함 매칭)
# ══════════════════════════════════════
# 시각적 유형별 매칭 키워드 + 대응 블록
# T-3 조사: 10개 고유 expression_hint → 5개 시각 유형 + 향후 2개
VISUAL_TYPE_KEYWORDS: dict[str, dict[str, Any]] = {
"인과": {
"keywords": ["인과", "현상->결과", "야기", "원인", "문제 상황"],
"blocks": ["callout-warning", "dark-bullet-list"],
},
"나열_병렬": {
"keywords": ["독립적 나열", "병렬 나열", "개별 증거", "병렬"],
"blocks": ["dark-bullet-list", "card-icon-desc"],
},
"나열_정의": {
"keywords": ["독립적 정의", "참조용", "용어", "정의 나열"],
"blocks": ["card-numbered"],
},
"포함_계층": {
"keywords": ["상위-하위", "포함 관계", "계층적", "포함하는", "구성요소"],
"blocks": ["venn-diagram", "keyword-circle-row"],
},
"강조_결론": {
"keywords": ["핵심 메시지 강조", "임팩트", "한 줄 강조", "결론적 판단"],
"blocks": ["banner-gradient", "quote-big-mark"],
},
"비교": {
"keywords": ["대등 비교", "좌우 대조", "vs", "A vs B"],
"blocks": ["compare-2col-split", "compare-3col-badge", "comparison-2col"],
},
"순서": {
"keywords": ["시간 순서", "단계별", "A->B->C", "프로세스 흐름"],
"blocks": ["flow-arrow-horizontal", "process-horizontal"],
},
}
# 카테고리별 fallback 블록 (모든 필터 통과 실패 시)
CATEGORY_FALLBACK: dict[str, str] = {
"cards": "card-numbered",
"emphasis": "dark-bullet-list",
"visuals": "venn-diagram",
"tables": "compare-2col-split",
"media": "image-side-text",
"headers": "topic-left-right",
}
# relation_type → 1차 필터 블록 카테고리 매핑
RELATION_CATEGORY_MAP: dict[str, list[str]] = {
"hierarchy": ["visuals", "emphasis"],
"inclusion": ["visuals", "emphasis"],
"comparison": ["tables", "emphasis", "cards"],
"sequence": ["visuals"],
"definition": ["cards", "emphasis"],
"cause_effect": ["emphasis"],
"none": ["emphasis"],
}
# ══════════════════════════════════════
# 카탈로그 로딩 (mtime 캐싱)
# ══════════════════════════════════════
_catalog_cache: dict[str, Any] = {"data": None, "mtime": 0}
def _load_catalog() -> list[dict]:
"""catalog.yaml 로드 (mtime 캐싱)."""
path = TEMPLATES_DIR / "catalog.yaml"
mtime = path.stat().st_mtime
if _catalog_cache["data"] is not None and _catalog_cache["mtime"] == mtime:
return _catalog_cache["data"]
data = yaml.safe_load(path.read_text(encoding="utf-8"))
blocks = data.get("blocks", [])
_catalog_cache["data"] = blocks
_catalog_cache["mtime"] = mtime
return blocks
def _get_block_by_id(block_id: str) -> dict | None:
"""블록 ID로 카탈로그 엔트리 조회."""
for b in _load_catalog():
if b["id"] == block_id:
return b
return None
# ══════════════════════════════════════
# 블록 선택 (2단계 필터)
# ══════════════════════════════════════
def _match_visual_type(expression_hint: str) -> tuple[str, list[str]]:
"""expression_hint에서 키워드를 찾아 시각적 유형과 후보 블록 반환.
키워드 포함(substring) 매칭 — 정확한 문자열 매칭이 아님.
T-3 조사: expression_hint는 긴 문장이므로 부분 매칭 필수.
"""
for vtype, spec in VISUAL_TYPE_KEYWORDS.items():
if any(kw in expression_hint for kw in spec["keywords"]):
return vtype, spec["blocks"]
return "default", []
# 배경 역할에서 제외할 다크 계열 블록
DARK_BLOCKS = {"dark-bullet-list", "card-dark-overlay"}
def select_reference_block(
relation_type: str,
expression_hint: str,
container_height_px: int,
zone: str = "body",
role: str = "",
) -> dict[str, Any]:
"""참고 블록 선택 (2단계 필터 + 역할 제약 + 컨테이너 적합성 + fallback).
Returns:
{
"block_id": str,
"variant": str,
"visual_type": str,
"catalog_entry": dict, # catalog.yaml의 해당 블록 전체
}
"""
catalog = _load_catalog()
# ── 1차 필터: relation_type → 카테고리 ──
allowed_categories = RELATION_CATEGORY_MAP.get(relation_type, ["emphasis"])
candidates_1 = [
b for b in catalog
if b.get("category") in allowed_categories
]
# ── 2차 필터: expression_hint 키워드 매칭 ──
visual_type, hint_blocks = _match_visual_type(expression_hint)
if hint_blocks:
candidates_2 = [b for b in candidates_1 if b["id"] in hint_blocks]
if not candidates_2:
candidates_2 = [b for b in catalog if b["id"] in hint_blocks]
else:
candidates_2 = candidates_1
# ── TP-1: 배경 역할은 다크 블록 제외 ──
if role == "배경":
candidates_2 = [b for b in candidates_2 if b["id"] not in DARK_BLOCKS]
if not candidates_2:
# 다크 제외 후 후보 없으면 라이트 fallback
candidates_2 = [b for b in candidates_1 if b["id"] not in DARK_BLOCKS]
# ── 3차 필터: 컨테이너 크기 적합성 ──
candidates_3 = [
b for b in candidates_2
if b.get("min_height_px", 0) <= container_height_px
]
# ── sidebar 제약: visuals/media 금지 ──
if zone == "sidebar":
candidates_3 = [
b for b in candidates_3
if b.get("category") not in ("visuals", "media")
and b.get("zone") != "full-width-only"
]
# ── 최종 선택 ──
if candidates_3:
selected = candidates_3[0]
elif candidates_2:
selected = candidates_2[0] # 크기 안 맞아도 최선
logger.warning(
f"[T-3] 컨테이너({container_height_px}px)에 맞는 블록 없음. "
f"최선 선택: {selected['id']} (min_height_px={selected.get('min_height_px')})"
)
else:
# fallback: 카테고리별 기본 블록
fallback_category = allowed_categories[0] if allowed_categories else "emphasis"
fallback_id = CATEGORY_FALLBACK.get(fallback_category, "dark-bullet-list")
selected = _get_block_by_id(fallback_id) or catalog[0]
visual_type = "fallback"
logger.warning(f"[T-3] 후보 없음. fallback: {selected['id']}")
# variant 선택: compact variant가 있고, 컨테이너가 블록 min_height_px 근처면 compact
variant = "default"
variants = selected.get("variants", [])
block_min_h = selected.get("min_height_px", 0)
if variants:
for v in variants:
# compact: 컨테이너 높이가 블록 min_height의 2배 미만이면 compact 사용
if v.get("id") == "compact" and container_height_px < block_min_h * 2:
variant = "compact"
break
return {
"block_id": selected["id"],
"variant": variant,
"visual_type": visual_type,
"catalog_entry": selected,
}
# ══════════════════════════════════════
# 디자인 레퍼런스 HTML 생성
# ══════════════════════════════════════
# 블록별 샘플 데이터 (Jinja2 변수 치환용)
_SAMPLE_DATA: dict[str, dict[str, Any]] = {
# emphasis
"dark-bullet-list": {
"title": "핵심 요약",
"bullets": ["첫 번째 포인트", "두 번째 포인트", "세 번째 포인트"],
},
"callout-warning": {
"title": "주의사항",
"description": "현재 접근 방식에 잠재적 문제가 있습니다.",
"icon": "⚠️",
},
"callout-solution": {
"title": "해결 방향",
"description": "체계적 접근이 필요합니다.",
"icon": "💡",
},
"banner-gradient": {
"text": "핵심 메시지 한 줄",
"sub_text": "부연 설명",
},
"comparison-2col": {
"left_title": "항목 A",
"left_content": "A의 특징과 설명",
"right_title": "항목 B",
"right_content": "B의 특징과 설명",
},
"quote-big-mark": {
"quote_text": "중요한 인용문 텍스트",
"source": "출처",
},
# cards
"card-numbered": {
"items": [
{"title": "항목 1", "description": "첫 번째 항목 설명"},
{"title": "항목 2", "description": "두 번째 항목 설명"},
{"title": "항목 3", "description": "세 번째 항목 설명"},
],
},
"card-icon-desc": {
"cards": [
{"icon": "🏗️", "title": "기술 A", "description": "기술 A 설명"},
{"icon": "🌍", "title": "기술 B", "description": "기술 B 설명"},
{"icon": "🔮", "title": "기술 C", "description": "기술 C 설명"},
],
},
# visuals
"venn-diagram": {
"center_label": "DX",
"center_sub": "디지털 전환",
"items": [
{"label": "BIM", "color": "#ff6b35"},
{"label": "GIS", "color": "#00d4aa"},
{"label": "DT", "color": "#ffd700"},
],
},
"keyword-circle-row": {
"keywords": [
{"letter": "B", "label": "BIM", "description": "건물정보모델링"},
{"letter": "G", "label": "GIS", "description": "지리정보시스템"},
{"letter": "D", "label": "DX", "description": "디지털 전환"},
],
},
"flow-arrow-horizontal": {
"steps": [
{"label": "분석"},
{"label": "설계"},
{"label": "시공"},
{"label": "관리"},
],
},
"process-horizontal": {
"steps": [
{"number": "1", "title": "현황 분석", "description": "현재 상태 진단"},
{"number": "2", "title": "전략 수립", "description": "로드맵 설계"},
{"number": "3", "title": "실행", "description": "단계적 도입"},
],
},
# tables
"compare-2col-split": {
"left_title": "기존",
"right_title": "개선",
"rows": [
{"left": "수작업", "center": "프로세스", "right": "자동화"},
{"left": "2D 도면", "center": "설계 도구", "right": "3D BIM"},
],
},
"compare-3col-badge": {
"headers": ["구분", "항목 A", "항목 B"],
"rows": [
["범위", "넓음", "좁음"],
["목적", "혁신", "관리"],
],
},
}
def generate_design_reference(
block_id: str,
variant: str = "default",
catalog_entry: dict | None = None,
) -> str:
"""블록의 디자인 레퍼런스 HTML 생성.
Jinja2 변수를 샘플 데이터로 치환한 완성 HTML + 구조 의도 주석.
LLM이 이 구조를 70~90% 복사 → "발명"하지 않고 검증된 구조를 따름.
"""
if catalog_entry is None:
catalog_entry = _get_block_by_id(block_id)
if catalog_entry is None:
logger.warning(f"[T-3] 블록 {block_id} 카탈로그에 없음")
return ""
# 템플릿 경로 결정
template_path = catalog_entry.get("template", "")
if variant != "default":
for v in catalog_entry.get("variants", []):
if v.get("id") == variant and v.get("template"):
template_path = v["template"]
break
if not template_path:
logger.warning(f"[T-3] 블록 {block_id} 템플릿 경로 없음")
return ""
# 샘플 데이터로 Jinja2 렌더링
sample = _SAMPLE_DATA.get(block_id, {})
try:
env = _get_jinja_env()
template = env.get_template(template_path)
rendered = template.render(**sample)
except Exception as e:
logger.warning(f"[T-3] 블록 {block_id} 렌더링 실패: {e}")
# 렌더링 실패 시 템플릿 원본 반환 (Jinja 변수 포함)
try:
raw = (TEMPLATES_DIR / template_path).read_text(encoding="utf-8")
rendered = raw
except Exception:
return ""
# 구조 의도 주석 추가
visual = catalog_entry.get("visual", "")
visual_diff = catalog_entry.get("visual_diff", "")
when = catalog_entry.get("when", "")
header = f"<!-- {block_id}: {visual[:80]} -->\n"
if visual_diff:
header += f"<!-- 차별점: {visual_diff[:100]} -->\n"
header += f"<!-- 적합 상황: {when[:80]} -->\n"
# schema 정보를 SLOT 주석으로 변환
schema = catalog_entry.get("schema", {})
if schema:
schema_comments = []
for slot_name, spec in schema.items():
if slot_name.startswith("max_"):
body_val = spec.get("body", "")
schema_comments.append(f"<!-- SLOT: {slot_name} = {body_val} -->")
else:
ml = spec.get("max_lines", "?")
fs = spec.get("font_size", "?")
rc = spec.get("ref_chars", {}).get("body", "?")
schema_comments.append(
f"<!-- SLOT: {slot_name} ({ml}줄, {fs}px, max {rc}자) -->"
)
header += "\n".join(schema_comments) + "\n"
return header + rendered
def select_and_generate_references(
topics: list[dict[str, Any]],
containers: dict[str, Any],
page_structure: dict[str, Any],
) -> dict[str, dict[str, Any]]:
"""역할별 참고 블록 선택 + 디자인 레퍼런스 HTML 생성.
Stage 1.7에서 호출. 각 역할(본심/배경/첨부/결론)에 대해
relation_type + expression_hint 기반으로 참고 블록을 선택하고
디자인 레퍼런스 HTML을 생성.
Returns:
{"본심": {"block_id": ..., "design_reference_html": ..., ...}, ...}
"""
references: dict[str, list[dict[str, Any]]] = {}
topic_map = {t.get("id"): t for t in topics}
for role, info in page_structure.items():
if not isinstance(info, dict):
continue
topic_ids = info.get("topic_ids", [])
if not topic_ids:
continue
# 컨테이너 정보
container = containers.get(role)
if container is None:
continue
if hasattr(container, "height_px"):
total_height_px = container.height_px
zone = container.zone
else:
total_height_px = container.get("height_px", 0) # 이전 Stage에서 반드시 제공
zone = container.get("zone", "body")
# V-1 + Phase V: 같은 영역 꼭지들의 layer 관계에 따라 블록 구조 결정
# layer가 다르면 → 주종 관계 → 블록 1개 (주 꼭지 기준, 종속은 하위 요소)
# layer가 같으면 → 동급 → 블록 N개 병렬
topic_layers = {tid: topic_map.get(tid, {}).get("layer", "") for tid in topic_ids}
unique_layers = set(topic_layers.values())
is_hierarchical = len(unique_layers) > 1 and len(topic_ids) > 1
from src.fit_verifier import _load_design_tokens
_tokens = _load_design_tokens()
gap_between = _tokens["spacing_small"]
if is_hierarchical:
# 주종 관계: 주 꼭지(intro/core) 기준으로 블록 1개 선택
# 종속 꼭지(supporting)는 블록 안에 하위 요소로 포함
primary_tid = None
supporting_tids = []
# layer 우선순위: core > intro > supporting > conclusion
layer_priority = {"core": 0, "intro": 1, "conclusion": 2, "supporting": 3}
sorted_tids = sorted(topic_ids, key=lambda t: layer_priority.get(topic_layers.get(t, ""), 9))
primary_tid = sorted_tids[0]
supporting_tids = sorted_tids[1:]
primary_topic = topic_map.get(primary_tid, {})
relation_type = primary_topic.get("relation_type", "none")
expression_hint = primary_topic.get("expression_hint", "")
selection = select_reference_block(
relation_type=relation_type,
expression_hint=expression_hint,
container_height_px=total_height_px,
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", {})
# 블록 1개에 모든 꼭지 정보를 담음
role_refs = [{
"block_id": selection["block_id"],
"variant": selection["variant"],
"visual_type": selection["visual_type"],
"schema_info": schema_info,
"design_reference_html": ref_html,
"topic_id": primary_tid,
"supporting_topic_ids": supporting_tids,
"is_hierarchical": True,
}]
logger.info(
f"[V-1] {role}: 주종 관계 → 블록 1개 ({selection['block_id']}), "
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,
})
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
return references

View File

@@ -304,11 +304,9 @@ async def _call_kei_editor_with_retry(prompt: str) -> str:
async with httpx.AsyncClient(timeout=None) as client:
async with client.stream(
"POST",
f"{kei_url}/api/message",
f"{kei_url}/api/direct",
json={
"message": full_prompt,
"session_id": "design-agent-editor",
"mode_hint": "chat",
},
timeout=None,
) as response:

View File

@@ -376,14 +376,15 @@ def verify_no_forbidden_content(
# Layer 3: 구조 검증
# ═══════════════════════════════════════════════════════════
# Phase T: overflow:hidden 필수 요구 제거.
# Phase T 프롬프트가 "overflow:hidden 금지"를 지시하므로 L3에서 요구하면 모순.
# 텍스트 잘림은 L4(Selenium 실측)에서 감지.
REQUIRED_PATTERNS: dict[str, list[str]] = {
"body_bg": ["overflow:hidden|overflow: hidden"],
"body_bg": [],
"body_core": [
"overflow:hidden|overflow: hidden",
"key-msg",
],
"sidebar": [
"overflow:hidden|overflow: hidden",
"padding-left",
"text-indent",
],
@@ -395,8 +396,12 @@ def verify_structure(
generated_html: str,
area_name: str,
has_image: bool = False,
font_hierarchy: dict | None = None,
) -> VerificationResult:
"""필수 CSS/HTML 패턴이 존재하는지 검증."""
"""필수 CSS/HTML 패턴이 존재하는지 검증.
Phase T-8: font_hierarchy가 제공되면 폰트 위계 위반도 검사.
"""
patterns = REQUIRED_PATTERNS.get(area_name, [])
missing = []
@@ -410,13 +415,36 @@ def verify_structure(
if "slide-img-" not in generated_html:
missing.append("slide-img-* (이미지 태그)")
# Phase T-8: 폰트 위계 검사
font_warnings = []
if font_hierarchy:
role_font_map = {
"body_bg": font_hierarchy.get("bg", 11),
"body_core": font_hierarchy.get("core", 12),
"sidebar": font_hierarchy.get("sidebar", 10),
"footer": font_hierarchy.get("core", 12),
}
max_font = role_font_map.get(area_name)
if max_font:
# HTML에서 font-size 값 추출
font_sizes = re.findall(r"font-size:\s*(\d+(?:\.\d+)?)\s*px", generated_html)
for fs_str in font_sizes:
fs = float(fs_str)
if fs > max_font + 1: # 1px 허용 오차
font_warnings.append(
f"폰트 위계 위반: {area_name}에서 {fs}px 사용 (최대 {max_font}px)"
)
passed = len(missing) == 0
all_errors = [f"필수 패턴 누락: {p}" for p in missing]
return VerificationResult(
passed=passed,
area_name=area_name,
checks={"structure": passed},
score=1.0 if passed else (1.0 - len(missing) / max(1, len(patterns))),
errors=[f"필수 패턴 누락: {p}" for p in missing],
errors=all_errors,
warnings=font_warnings,
)
@@ -551,18 +579,35 @@ async def generate_with_retry(
return [topic_map[tid] for tid in info.get("topic_ids", []) if tid in topic_map]
area_texts = {}
def _get_role_text(role_topics):
"""structured_text 우선, 없으면 source_hint 키워드로 sections 매칭."""
texts = []
for t in role_topics:
st = t.get("structured_text", "")
if st:
texts.append(st)
else:
# fallback: source_hint에서 키워드 추출하여 매칭
hint = t.get("source_hint", "")
keywords = [w for w in hint.split() if len(w) >= 2][:3]
matched = _map_sections_for_role(sections, [t], keywords) if keywords else ""
if matched:
texts.append(matched)
return "\n\n".join(texts) if texts else ""
bg_topics = get_topics_for_role("배경")
if bg_topics:
area_texts["body_bg"] = _map_sections_for_role(sections, bg_topics, ["혼용", "사례"])
area_texts["body_bg"] = _get_role_text(bg_topics)
core_topics = get_topics_for_role("본심")
if core_topics:
area_texts["body_core"] = _map_sections_for_role(sections, core_topics, ["관계", "핵심기술", "DX"])
area_texts["body_core"] = _get_role_text(core_topics)
ref_topics = get_topics_for_role("첨부")
if ref_topics:
area_texts["sidebar"] = _get_definitions(content)
area_texts["sidebar"] = _get_role_text(ref_topics)
conclusion_topics = get_topics_for_role("결론")
if conclusion_topics:
area_texts["footer"] = _get_conclusion(content)
area_texts["footer"] = _get_role_text(conclusion_topics)
has_image_areas = set()
if images:

View File

@@ -509,11 +509,9 @@ async def _opus_batch_recommend(
async with httpx.AsyncClient(timeout=None) as client:
async with client.stream(
"POST",
f"{kei_url}/api/message",
f"{kei_url}/api/direct",
json={
"message": prompt,
"session_id": "design-agent-p-recommend",
"mode_hint": "chat",
},
timeout=None,
) as response:
@@ -615,11 +613,9 @@ async def _opus_block_recommendation(
async with httpx.AsyncClient(timeout=None) as client:
async with client.stream(
"POST",
f"{kei_url}/api/message",
f"{kei_url}/api/direct",
json={
"message": prompt,
"session_id": "design-agent-opus",
"mode_hint": "chat",
},
timeout=None,
) as response:

1040
src/fit_verifier.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,9 @@
"""Phase S: AI HTML 생성기 — 검증 합격 프롬프트 템플릿 기반.
"""Phase T: AI HTML 생성기 — 동적 프롬프트 생성.
영역별 개별 호출. 검증에서 합격한 프롬프트의 구조/디자인은 고정, 텍스트만 동적.
영역별 개별 호출. Phase T context(폰트 위계, 블록 레퍼런스, 디자인 예산)에서
모든 수치를 동적으로 가져와 프롬프트를 조립.
Phase S 하드코딩 프롬프트(BG_PROMPT 등) → build_area_prompt() 동적 생성으로 교체.
역할 분리:
Kei (1단계): 콘텐츠 분석
@@ -22,51 +25,311 @@ logger = logging.getLogger(__name__)
# ═══════════════════════════════════════════════════════════
# 검증 합격 프롬프트 템플릿
# 구조/디자인은 고정. {변수}만 동적 교체.
# Phase T: 동적 프롬프트 생성
# Phase S 하드코딩 프롬프트 → context 기반 동적 생성으로 교체
# ═══════════════════════════════════════════════════════════
BG_PROMPT = """다음 콘텐츠를 배경(보조) 영역 HTML로 만들어라.
## 핵심 원칙
이 영역은 **보조 영역**이다. 본심(핵심 콘텐츠)보다 시각적으로 약해야 한다.
다크 배경 절대 금지. 흰색/연회색 위에 텍스트를 놓는 라이트 디자인으로.
## 크기
- width: 100%, height: {height}px (고정, overflow:hidden)
## 콘텐츠 (축약/요약/삭제 금지. 원본 텍스트를 그대로 사용.)
{content_block}
## 텍스트 규칙 (반드시 적용)
# 공통 텍스트 규칙 (모든 영역 동일)
_COMMON_TEXT_RULES = """## 텍스트 규칙 (반드시 적용)
1. 원본 텍스트의 단어를 한 글자도 빼지 마라. 축약/요약 절대 금지.
2. 마침표(.)로 끝나는 문장이 2개 이상이면 각각 별도 불릿(•)으로 분리.
3. 개조식 어미 변환: 문장 끝 1-2글자만 변환. 그 외 단어는 절대 건드리지 마라.
- "~있다""~있음", "~한다""~함", "~이다" → 삭제, "~된다""~됨"
예: "인식되고 있다""인식되고 있음" (단어 삭제 없이 끝만 변환)
4. 원본에 없는 텍스트를 추가하지 마라.
5. 동일한 내용을 다른 형태로 2번 넣지 마라. 상세 내용은 "[상세보기]" 텍스트 링크만 남기고 본문에서 제거."""
## 디자인
- 배경: background: #f8fafc (연회색, 다크 배경 절대 금지)
- border: 1px solid #e2e8f0, border-radius: 6px
- 전체 padding: 10px 14px (여백 최소화)
- 제목: 12px bold #334155, margin-bottom: 4px
- 본문: 11px #475569, line-height: 1.4, 핵심 키워드 <strong style="color:#1e293b"> 처리
- 토픽이 여러 개이면 가로로 나란히 (flex, gap:8px)
- 각 토픽 구분: background:#ffffff, border-left: 2px solid #94a3b8, padding: 6px 8px (여백 최소화)
- 토픽 제목: 10px bold #334155, margin-bottom: 2px
- 토픽 내용: 9px #64748b, line-height: 1.3
- 들여쓰기: 불릿은 인라인 style만 사용. CSS class 사용 금지 (<style> 블록 금지).
불릿 마커: 텍스트로 "" 직접 삽입 (::before 금지)
들여쓰기: style="padding-left:14px; text-indent:-14px;" 인라인으로.
- 폰트를 줄여서라도 높이 안에 맞출 것. overflow:hidden이므로 넘치면 잘림.
# 공통 HTML 규칙
_COMMON_HTML_RULES = """## HTML 규칙
- inline style만 사용. <style> 블록 금지.
- overflow:hidden 금지 (텍스트 잘림 방지).
- 모든 텍스트가 보여야 한다. 잘리는 텍스트가 있으면 안 됨.
- <style> 블록을 만들지 마라. 모든 스타일을 인라인 style 속성으로만 적용하라.
HTML만 반환. <style> 블록 금지. 설명 없이 코드만."""
- HTML만 반환. 설명 없이 코드만."""
CORE_PROMPT = """다음 콘텐츠를 본심 영역 HTML로 만들어라.
def _calc_indent(font_size: float) -> tuple[int, int]:
"""폰트 크기에 맞는 들여쓰기 px 계산.
불릿 마커 "" 폭 ≈ font_size × 1.2.
Returns: (padding_left, text_indent)
"""
import math
pl = math.ceil(font_size * 1.2)
return pl, -pl
def build_area_prompt(
role: str,
content_block: str,
phase_t: dict,
height_px: int,
width_px: int,
images: list[dict] | None = None,
core_message: str = "",
) -> str:
"""Phase T context에서 모든 수치를 동적으로 가져와 프롬프트 생성.
하드코딩 CSS 값 0개. 모든 수치는 phase_t context에서.
Args:
role: "배경" | "본심" | "첨부" | "결론"
content_block: 원본 텍스트 (이 영역에 해당하는)
phase_t: analysis["phase_t"] dict (font_hierarchy, references, design_budgets, container_ratio)
height_px: 이 영역의 높이
width_px: 이 영역의 너비
images: 이미지 정보 (본심에서만)
core_message: 핵심 메시지 (본심에서만)
"""
fh = phase_t.get("font_hierarchy", {})
refs = phase_t.get("references", {})
budgets = phase_t.get("design_budgets", {})
# 역할별 폰트 매핑
role_font_map = {"배경": "bg", "본심": "core", "첨부": "sidebar", "결론": "key_msg"}
font_size = fh.get(role_font_map.get(role, "core"), 12)
# 들여쓰기 (폰트 크기 기반)
indent_pl, indent_ti = _calc_indent(font_size)
# V-1: 꼭지별 블록 레퍼런스 (리스트)
ref_list = refs.get(role, [])
if isinstance(ref_list, dict):
# 하위호환: 이전 형식(dict) → 리스트로 변환
ref_list = [ref_list]
# 모든 블록의 디자인 레퍼런스 HTML을 결합
ref_html = "\n\n".join(r.get("design_reference_html", "") for r in ref_list if r)
# 디자인 예산
budget = budgets.get(role, {})
avail_h = budget.get("available_height_px", 0)
avail_w = budget.get("available_width_px", 0)
parts = []
# ── Phase V Step 7: 서브 컨테이너 레이아웃 ──
sub_layouts = phase_t.get("sub_layouts", {})
role_layout = sub_layouts.get(role, {})
sub_containers = role_layout.get("sub_containers", [])
if sub_containers:
layout_lines = [f"## 세부 레이아웃 (Phase V Step 7) — 반드시 이 구조를 따르라"]
for sc in sub_containers:
sc_name = sc.get("name", "")
sc_w = int(sc.get("width_px", 0))
sc_h = int(sc.get("height_px", 0))
sc_align = sc.get("align", "stretch")
layout_lines.append(f"- {sc_name}: {sc_w}×{sc_h}px (align: {sc_align})")
# 서브 컨테이너 조합 지시
names = [sc.get("name", "") for sc in sub_containers]
if "svg" in names and "text_and_table" in names:
svg_sc = next(sc for sc in sub_containers if sc["name"] == "svg")
txt_sc = next(sc for sc in sub_containers if sc["name"] == "text_and_table")
layout_lines.append(f"\n구조: SVG({int(svg_sc['width_px'])}px, 좌측) + 텍스트/표({int(txt_sc['width_px'])}px, 우측) — display:flex")
if "keymsg" in names:
layout_lines.append("key-msg: 컨테이너 최하단 전체 폭, flex-shrink:0")
table_rows = role_layout.get("table_rows", 0)
if table_rows > 0:
layout_lines.append(f"보충 표: {table_rows}행 (텍스트 아래 여유 공간에 배치)")
parts.append("\n".join(layout_lines) + "\n")
# ── Phase V: Stage 1.8 결과를 프롬프트에 반영 ──
fit_result = phase_t.get("fit_result", {})
enhancements = phase_t.get("enhancements", {})
# 재배분된 컨테이너 크기
redist = fit_result.get("redistribution", {})
if redist.get(role):
redistributed_h = int(redist[role])
parts.append(f"## 컨테이너 크기 (재배분 후)\n- height: {redistributed_h}px, width: {width_px}px\n- 이 크기를 절대 초과하지 마라. overflow 금지.\n")
# 강조 블록
for eb in enhancements.get("emphasis_blocks", []):
if eb.get("role") == role:
sentence = eb.get("sentence", "")
parts.append(f"## 강조 (Phase V)\n다음 문장을 강조 블록으로 처리하라 (배경색 반전, bold):\n\"{sentence}\"\n")
# bold 키워드
role_bolds = enhancements.get("bold_keywords", {}).get(role, [])
if role_bolds:
parts.append(f"## bold 키워드 (Phase V)\n다음 키워드가 본문에 나올 때 <strong>으로 감싸라:\n{role_bolds}\n")
# 보충 블록 + Step 7 표 행 수
table_rows = role_layout.get("table_rows", 0)
for sb in enhancements.get("supplement_blocks", []):
if sb.get("role") == role:
row_hint = f"\n- 표 행 수: {table_rows}행 (Step 7 계산 결과)" if table_rows > 0 else ""
parts.append(f"## 보충 콘텐츠 (Phase V)\n여유 공간에 다음 콘텐츠의 핵심 요약을 넣어라:\n- 출처: {sb.get('content_source', '')}\n- 블록: {sb.get('block_id', '')}{row_hint}\n")
# V-4: Kei 에스컬레이션 결정 (공간 부족 시 Kei가 내린 판단)
for kd in enhancements.get("kei_decisions", []):
if kd.get("role") == role:
action = kd.get("action", "")
detail = kd.get("detail", "")
if action == "inline":
parts.append(f"## Kei 결정 (V-4): 인라인 축약\n{detail}\n사례/근거를 괄호 한 줄로 축약하라. 상세는 팝업 링크로.\n")
elif action == "trim":
parts.append(f"## Kei 결정 (V-4): 텍스트 축약\n{detail}\n핵심만 남기고 분량을 줄여라.\n")
elif action == "popup":
parts.append(f"## Kei 결정 (V-4): 팝업 분리\n{detail}\n상세 내용을 제거하고 \"상세보기\" 링크만 남겨라.\n")
elif action == "merge":
parts.append(f"## Kei 결정 (V-4): 꼭지 합치기\n{detail}\n여러 꼭지를 하나의 흐름으로 자연스럽게 연결하라.\n")
# V-7: 종속 꼭지 처리 지시
for st in enhancements.get("subordinate_treatments", []):
if st.get("role") == role:
detail = st.get("detail", {})
treatment = detail.get("treatment", "inline")
s_tid = detail.get("supporting_topic_id", "?")
s_purpose = detail.get("has_popup", False)
popup_title = detail.get("popup_title", "")
if treatment == "inline":
parts.append(f"## 종속 꼭지 처리 (V-7)\n꼭지{s_tid}의 내용을 인라인 1~2줄로 축약하여 주 블록 안에 삽입하라.\n" +
(f"팝업 \"{popup_title}\" 참조가 있으면 링크만 남겨라.\n" if popup_title else ""))
elif treatment == "sub_block":
parts.append(f"## 종속 꼭지 처리 (V-7)\n꼭지{s_tid}의 내용을 하위 블록(border-left + 들여쓰기)으로 분리하여 주 블록 아래에 배치하라.\n")
# ── 들여쓰기 예시 HTML (TP-4: Sonnet이 정확히 따르도록 구체적 예시 제공) ──
indent_example = f"""<div style="padding-left:{indent_pl}px; text-indent:{indent_ti}px; font-size:{font_size}px;">• 첫줄 텍스트가 여기서 시작하고
둘째줄도 정확히 같은 위치에서 시작한다</div>"""
# ── 역할별 지시 ──
if role == "배경":
parts.append(f"""다음 콘텐츠를 배경(보조) 영역 HTML로 만들어라.
## 핵심 원칙
이 영역은 **보조 영역**이다. 본심(핵심)보다 시각적으로 **반드시 약해야** 한다.
- 다크 배경(#1a~#2a 계열) 절대 금지. 밝은 톤만 사용.
- 본심이 슬라이드의 주인공. 이 영역은 조용하고 가벼워야 한다.
## 크기
- width: 100% (body 영역 전체 폭), height: {height_px}px
- 본심과 가로 폭이 반드시 동일해야 한다.
## 폰트
- 이 영역의 폰트: {font_size}px. 제목은 {font_size + 1}px bold.
- 이보다 큰 폰트 사용 금지. (위계: 핵심{fh.get('key_msg',14)}px > 본심{fh.get('core',12)}px >= 배경{font_size}px > 첨부{fh.get('sidebar',10)}px)
## 들여쓰기 — 반드시 아래 예시를 정확히 따라라 (TP-4)
{indent_example}
불릿이 있으면 반드시 위 style을 그대로 사용. padding-left:{indent_pl}px; text-indent:{indent_ti}px;""")
elif role == "본심":
img_instruction = ""
if images:
for img in images:
img_id = f"slide-img-{img.get('topic_id', '')}"
img_instruction = f"""
## 이미지 (TP-2: 텍스트가 주인공, 이미지는 보조)
- <img id="{img_id}" src="placeholder"> (후처리에서 교체)
- 이미지는 반드시 float:right. 텍스트 옆에 배치. 이미지가 전체 폭을 차지하면 안 됨.
- 이미지 width: 최대 250px. 텍스트가 이미지를 감싸도록.
- 이미지가 주인공이 아니다. 텍스트가 주인공이다."""
parts.append(f"""다음 콘텐츠를 본심(핵심) 영역 HTML로 만들어라.
## 핵심 원칙 (TP-2)
이 영역이 슬라이드의 **주인공**이다. 가장 큰 시각적 비중.
- **텍스트가 주인공**, 이미지/도형은 텍스트를 보조하는 역할.
- 핵심 메시지(key-msg)가 시각적으로 **가장 눈에 띄어야** 함.
- key-msg에 배경색 + 테두리 + 큰 폰트를 적용하여 강조.
## 크기
- width: 100% (body 영역 전체 폭), max-height: {height_px}px
- 배경과 가로 폭이 반드시 동일해야 한다.
## 폰트
- 이 영역의 본문 폰트: {font_size}px. line-height: 1.75.
- 핵심 메시지(key-msg): {fh.get('key_msg', 14)}px bold. 반드시 class="key-msg" 포함.
- 이 영역에서 {fh.get('key_msg', 14)}px보다 큰 폰트 사용 금지.
## 들여쓰기 — 반드시 아래 예시를 정확히 따라라 (TP-4)
{indent_example}
주불릿: padding-left:{indent_pl}px; text-indent:{indent_ti}px;
부불릿: padding-left:{indent_pl * 2}px; text-indent:{indent_ti}px;
## 핵심 메시지 (반드시 포함)
- 하단에 key-msg 영역: "{core_message}"
- HTML: <div class="key-msg" style="font-size:{fh.get('key_msg', 14)}px; font-weight:bold; padding:8px; border-radius:6px; text-align:center; margin-top:10px;">...</div>
## 팝업/상세 내용 (TP-5: 링크 위치)
- 상세 내용(비교표 등)은 본문에 넣지 마라. 별도 첨부 파일로 분리됨.
- "상세보기" 링크를 **해당 섹션 제목 옆 우측**에 작게 배치 (10px, #2563eb).
- 예시:
<div style="display:flex; justify-content:space-between; align-items:center;">
<span style="font-weight:bold;">섹션 제목</span>
<span style="font-size:10px; color:#2563eb;">상세보기 →</span>
</div>
- 본문 중간에 한 줄로 넣지 마라. 동일 내용을 2번 넣지 마라.
{img_instruction}""")
elif role == "첨부":
parts.append(f"""다음 콘텐츠를 sidebar 영역 HTML로 만들어라.
## 크기 (TP-3: 잘림 방지)
- width: 100% (부모 grid cell에 맞춤). height: {height_px}px.
- 최외곽 div에 width:100%를 쓰라. 절대 px 값으로 width를 지정하지 마라.
- 이 크기 안에 **모든 내용이 들어가야** 한다. 넘치면 폰트를 줄여서 맞춰라.
- 각 카드 width: 100%. 컨테이너 밖으로 넘치면 안 됨.
- word-break: break-word (긴 영문도 줄바꿈)
## 폰트
- 이 영역의 폰트: {font_size}px. 제목은 {font_size + 1}px bold.
- 이보다 큰 폰트 사용 금지. (위계: 이 영역은 가장 작은 폰트)
## 들여쓰기 — 반드시 아래 예시를 정확히 따라라 (TP-4)
{indent_example}
## 카드 구조
- 각 용어를 카드로 구분. 카드 내부 padding 포함하여 width 100% 안에 맞출 것.
- 카드 간 간격 8px.
- 출처가 있으면 카드 하단에 작게 ({max(font_size - 2, 8)}px).""")
elif role == "결론":
parts.append(f"""다음 콘텐츠를 결론 배너 HTML로 만들어라.
## 크기
- width: 100%, height: {height_px}px
## 폰트
- 핵심 메시지: {font_size}px bold white
- 이 영역은 핵심 메시지 한 줄. 가장 큰 폰트.""")
# ── 공통: 콘텐츠 ──
parts.append(f"""
## 콘텐츠 (축약/요약/삭제 금지. 원본 텍스트를 그대로 사용.)
{content_block}""")
# ── 공통: 텍스트 규칙 ──
parts.append(_COMMON_TEXT_RULES)
# ── 블록 레퍼런스 (있으면) ──
if ref_html:
if len(ref_html) > 3000:
ref_html = ref_html[:3000] + "\n<!-- truncated -->"
parts.append(f"""
## 디자인 레퍼런스 — 이 HTML의 구조와 색상 패턴을 따르되, 콘텐츠를 교체하라.
구조(레이아웃, 색상 배치, 카드/불릿 패턴)를 따르고, 텍스트만 원본으로 교체.
발명하지 마라. 이 구조를 따라라.
{ref_html}""")
# ── 디자인 예산 (있으면) ──
if avail_h > 0:
parts.append(f"""
## 디자인 예산
- 텍스트 영역 확보 후 남은 공간: 높이 {avail_h}px, 너비 {avail_w}px
- 도형/이미지/배경색 영역은 이 예산 안에서 배치.""")
# ── 공통: HTML 규칙 ──
parts.append(_COMMON_HTML_RULES)
return "\n".join(parts)
# Phase S 레거시 프롬프트 — build_area_prompt()로 교체됨. 참고용으로만 보존.
_LEGACY_CORE_PROMPT = """다음 콘텐츠를 본심 영역 HTML로 만들어라.
## 크기: width:100%, max-height: {height}px, overflow: hidden (반드시 적용)
@@ -218,7 +481,7 @@ CORE_PROMPT = """다음 콘텐츠를 본심 영역 HTML로 만들어라.
HTML + inline <style>만 반환. 위 CSS와 HTML 구조를 정확히 따르라. 설명 없이 코드만."""
SIDEBAR_PROMPT = """다음 용어 정의를 sidebar 카드로 만들어라. {width}px × {height}px.
_LEGACY_SIDEBAR_PROMPT = """다음 용어 정의를 sidebar 카드로 만들어라. {width}px × {height}px.
## 용어 (축약/요약/삭제 금지. 원본 텍스트를 한 글자도 바꾸지 말고 그대로 사용.)
{definitions_block}
@@ -243,7 +506,7 @@ SIDEBAR_PROMPT = """다음 용어 정의를 sidebar 카드로 만들어라. {wid
HTML만 반환. <style> 블록 금지. 모든 스타일은 인라인 style 속성으로. 설명 없이 코드만."""
FOOTER_PROMPT = """결론 배너 HTML.
_LEGACY_FOOTER_PROMPT = """결론 배너 HTML.
## 콘텐츠 (축약/요약/삭제 금지. 원본 텍스트를 그대로 사용.)
{content_block}
@@ -262,6 +525,68 @@ FOOTER_PROMPT = """결론 배너 HTML.
HTML + inline <style>만 반환. 설명 없이 코드만."""
# ═══════════════════════════════════════════════════════════
# Phase T-7: 프롬프트에 레퍼런스 + 수치 주입
# ═══════════════════════════════════════════════════════════
def _build_phase_t_supplement(role: str, analysis: dict) -> str:
"""Phase T context가 있으면 프롬프트 보충 섹션을 생성.
폰트 위계, 디자인 예산, 레퍼런스 HTML을 구체적 수치로 전달.
Phase S 교훈: "구체적 프롬프트는 합격, 추상적 프롬프트는 실패"
→ px, 폰트 크기, 줄 수를 숫자로 넣되 context에서 동적으로 가져옴.
"""
phase_t = analysis.get("phase_t")
if not phase_t:
return ""
parts = []
# 1. 폰트 위계 (역할별 확정 폰트)
fh = phase_t.get("font_hierarchy", {})
role_font_map = {"배경": "bg", "본심": "core", "첨부": "sidebar", "결론": "core"}
font_key = role_font_map.get(role, "core")
font_size = fh.get(font_key, 12)
parts.append(
f"\n[폰트 위계 — 반드시 준수]\n"
f"이 영역({role})의 확정 폰트: {font_size}px\n"
f"전체 위계: 핵심={fh.get('key_msg', 14)}px > 본심={fh.get('core', 12)}px "
f">= 배경={fh.get('bg', 11)}px > 첨부={fh.get('sidebar', 10)}px\n"
f"이 영역에서 {font_size}px보다 큰 폰트를 사용하지 마라. 위계가 깨진다."
)
# 2. 디자인 예산 (남은 공간)
budgets = phase_t.get("design_budgets", {})
budget = budgets.get(role, {})
if budget:
parts.append(
f"\n[디자인 예산]\n"
f"텍스트 영역 확보 후 남은 공간:\n"
f"- 가용 높이: {budget.get('available_height_px', 0)}px\n"
f"- 가용 너비: {budget.get('available_width_px', 0)}px\n"
f"- 원형 요소 최대: {budget.get('max_circle_diameter', 0)}px\n"
f"- 이미지 최대: {budget.get('max_img_width', 0)}×{budget.get('max_img_height', 0)}px\n"
f"디자인 요소(도형, 이미지, 배경색 영역)는 이 예산 안에서 배치하라."
)
# 3. V-1: 꼭지별 디자인 레퍼런스 HTML (리스트)
refs = phase_t.get("references", {})
ref_list = refs.get(role, [])
if isinstance(ref_list, dict):
ref_list = [ref_list]
ref_html = "\n\n".join(r.get("design_reference_html", "") for r in ref_list if r)
if ref_html:
# 너무 길면 잘라서 토큰 절약
if len(ref_html) > 3000:
ref_html = ref_html[:3000] + "\n<!-- ... (truncated) -->"
parts.append(
f"\n[디자인 레퍼런스 — 구조와 색상 패턴을 참고하되 그대로 복사하지 마라]\n"
f"{ref_html}"
)
return "\n".join(parts) if parts else ""
# ═══════════════════════════════════════════════════════════
# 메인 함수
# ═══════════════════════════════════════════════════════════
@@ -301,94 +626,120 @@ async def generate_slide_html(
result = {"body_html": "", "sidebar_html": "", "footer_html": "", "reasoning": ""}
# ── 실제 zone 높이 계산 ──
# slide=720, padding=40*2=80, grid-gap=20*2=40, header≈66px(2rem*1.7+padding+border)
# body_zone = 720 - 80 - 66 - footer - 40
footer_h = concl_spec.height_px if concl_spec else 60
body_zone_h = 720 - 80 - 66 - footer_h - 40 # ≈ 474
sidebar_zone_h = body_zone_h # body와 sidebar는 같은 grid row
# ── 실제 zone 높이: containers에서 온 값 사용 (하드코딩 아님) ──
from src.fit_verifier import _load_design_tokens
tokens = _load_design_tokens()
bg_h = bg_spec.height_px if bg_spec else 0
core_h = core_spec.height_px if core_spec else 0
footer_h = concl_spec.height_px if concl_spec else 0
sidebar_h = ref_spec.height_px if ref_spec else 0
# body zone = 배경 + 본심 + gap
bg_core_gap = tokens["spacing_small"]
body_zone_h = bg_h + core_h + (bg_core_gap if bg_topics and core_topics else 0)
sidebar_zone_h = sidebar_h if sidebar_h > 0 else body_zone_h
# core_max_h: 본심 컨테이너 높이에서 key-msg 높이를 빼야 Sonnet이 넘치지 않음
phase_t = analysis.get("phase_t", {})
core_sub = phase_t.get("sub_layouts", {}).get("본심", {})
keymsg_sub_h = 0
for sc in core_sub.get("sub_containers", []):
if sc.get("name") == "keymsg":
keymsg_sub_h = sc.get("height_px", 0)
core_max_h = core_h - keymsg_sub_h if core_h > 0 else (body_zone_h - bg_h - bg_core_gap if bg_topics else body_zone_h)
logger.info(f"[Phase S] zone 계산: body={body_zone_h}px, sidebar={sidebar_zone_h}px, bg={bg_h}px, core_max={core_max_h}px (keymsg={keymsg_sub_h}px 제외)")
BG_CORE_GAP = 12 # 배경↔본심 간격
bg_h = bg_spec.height_px if bg_spec else 176
# 본심은 body zone에서 배경+gap을 뺀 나머지
core_max_h = body_zone_h - bg_h - BG_CORE_GAP if bg_topics else body_zone_h
logger.info(f"[Phase S] zone 계산: body={body_zone_h}px, sidebar={sidebar_zone_h}px, bg={bg_h}px, core_max={core_max_h}px")
# Phase T context
phase_t = analysis.get("phase_t", {})
# 원본 텍스트 매핑
sections = _slice_mdx_sections(content)
# ── 콘텐츠 텍스트 가져오기: structured_text 우선, 없으면 sections 매칭 fallback ──
def _get_role_content(role_topics):
"""structured_text를 우선 사용. 없으면 기존 sections 매칭."""
texts = []
for t in role_topics:
st = t.get("structured_text", "")
if st:
texts.append(st)
else:
# fallback: source_hint 키워드로 sections에서 매칭
keywords = _extract_keywords_from_hints([t])
matched = _map_sections_for_role(sections, [t], keywords)
if matched:
texts.append(matched)
return "\n\n".join(texts) if texts else ""
# ── 배경 ──
if bg_topics:
logger.info("[Phase S] 배경 생성...")
sections = _slice_mdx_sections(content)
bg_content = _map_sections_for_role(
sections, bg_topics, _extract_keywords_from_hints(bg_topics),
)
prompt = BG_PROMPT.format(
height=bg_h,
logger.info("[Phase T] 배경 생성...")
bg_content = _get_role_content(bg_topics)
body_width = bg_spec.width_px if bg_spec else (core_spec.width_px if core_spec else 0)
prompt = build_area_prompt(
role="배경",
content_block=bg_content,
phase_t=phase_t,
height_px=bg_h,
width_px=body_width,
)
html = await _call_claude(client, prompt)
if html:
result["body_html"] += html + f'\n<div style="height:{BG_CORE_GAP}px;"></div>\n'
logger.info(f"[Phase S] 배경 완료: {len(html)}")
result["body_html"] += html + f'\n<div style="height:{bg_core_gap}px;"></div>\n'
logger.info(f"[Phase T] 배경 완료: {len(html)}")
# ── 본심 ──
if core_topics:
logger.info("[Phase S] 본심 생성...")
core_content = _map_sections_for_role(
sections, core_topics, _extract_keywords_from_hints(core_topics),
)
img_instruction = ""
img_margin = 60
img_w = 250
for img in images:
if img.get("topic_id") in [t["id"] for t in core_topics]:
img_id = f"slide-img-{img['topic_id']}"
img_instruction = f"이미지 태그: <img id=\"{img_id}\" src=\"placeholder\">\nid=\"{img_id}\"를 반드시 포함 (후처리에서 실제 이미지로 교체)"
if img.get("ratio", 1) > 1.5:
img_w = 250
img_margin = 60
prompt = CORE_PROMPT.format(
width=core_spec.width_px if core_spec else 767,
height=core_max_h,
img_margin_top=img_margin,
img_width=img_w,
core_message=analysis.get("core_message", ""),
logger.info("[Phase T] 본심 생성...")
core_content = _get_role_content(core_topics)
core_images = [img for img in images if img.get("topic_id") in [t["id"] for t in core_topics]]
body_width = core_spec.width_px if core_spec else (bg_spec.width_px if bg_spec else 0)
prompt = build_area_prompt(
role="본심",
content_block=core_content,
img_instruction=img_instruction,
phase_t=phase_t,
height_px=core_max_h,
width_px=body_width,
images=core_images,
core_message=analysis.get("core_message", ""),
)
html = await _call_claude(client, prompt)
if html:
html = _replace_img_placeholder(html, images)
result["body_html"] += html + "\n"
logger.info(f"[Phase S] 본심 완료: {len(html)}")
logger.info(f"[Phase T] 본심 완료: {len(html)}")
# ── sidebar ──
if ref_topics:
logger.info("[Phase S] sidebar 생성...")
defs = _get_definitions(content)
prompt = SIDEBAR_PROMPT.format(
width=ref_spec.width_px if ref_spec else 380,
height=sidebar_zone_h,
definitions_block=defs,
logger.info("[Phase T] sidebar 생성...")
sidebar_content = _get_role_content(ref_topics)
sidebar_width = ref_spec.width_px if ref_spec else 0
prompt = build_area_prompt(
role="첨부",
content_block=sidebar_content,
phase_t=phase_t,
height_px=sidebar_zone_h,
width_px=sidebar_width,
)
html = await _call_claude(client, prompt)
if html:
result["sidebar_html"] = html
logger.info(f"[Phase S] sidebar 완료: {len(html)}")
logger.info(f"[Phase T] sidebar 완료: {len(html)}")
# ── footer ──
if conclusion_topics:
logger.info("[Phase S] footer 생성...")
footer_content = _get_conclusion(content)
prompt = FOOTER_PROMPT.format(
height=concl_spec.height_px if concl_spec else 60,
logger.info("[Phase T] footer 생성...")
footer_content = _get_role_content(conclusion_topics) or _get_conclusion(content)
footer_width = concl_spec.width_px if concl_spec else 0
prompt = build_area_prompt(
role="결론",
content_block=footer_content.strip(),
phase_t=phase_t,
height_px=concl_spec.height_px if concl_spec else 0,
width_px=footer_width,
)
html = await _call_claude(client, prompt)
if html:
result["footer_html"] = html
logger.info(f"[Phase S] footer 완료: {len(html)}")
logger.info(f"[Phase T] footer 완료: {len(html)}")
result["reasoning"] = "영역별 개별 호출, 검증 합격 프롬프트 템플릿 사용."
return result
@@ -398,17 +749,98 @@ async def generate_slide_html(
# 콘텐츠 추출 함수
# ═══════════════════════════════════════════════════════════
def _slice_mdx_sections(content: str) -> dict[str, str]:
"""원본 MDX를 ## 기준으로 섹션별 슬라이싱.
def normalize_mdx(raw_mdx: str) -> str:
"""MDX를 ## 섹션 기반 표준 구조로 정규화 (0단계).
source_data(Kei 메모 포함)를 사용하지 않고,
원본 MDX 텍스트를 그대로 추출하여 프롬프트에 넣는다.
다양한 MDX 포맷을 ## 섹션 + 순수 텍스트로 통일.
패턴은 계속 추가될 수 있음 — MDX 문법 기반 범용 처리.
처리 패턴:
- frontmatter (---...---) 제거
- import 문 제거
- <br/>, 장식용 --- 제거
- JSX div style 태그 → 내부 텍스트만
- 커스텀 컴포넌트 (<Component />) 제거
- <details><summary> → 태그 제거, 내용 유지
- :::directive[제목] → ## 승격
- ## N. 제목 → ## 제목 (번호 제거)
- ### N.N 제목 → ### 제목 (번호 제거)
- * **제목** (## 전 도입부) → ## 승격
- ![alt](path) → [이미지] 참조 보존
- *이탤릭 출처* → 출처: 텍스트
"""
text = raw_mdx
# frontmatter 제거
text = re.sub(r"^---\n.*?\n---\n*", "", text, flags=re.DOTALL)
# import 문 제거
text = re.sub(r"^import\s+.+$", "", text, flags=re.MULTILINE)
# <br/> 제거
text = re.sub(r"<br\s*/?>", "", text)
# JSX div style → 태그만 제거
text = re.sub(r"<div\s+style=\{\{[^}]*\}\}>", "", text)
text = text.replace("</div>", "")
# 커스텀 컴포넌트 태그 제거 (<Component />, <Component>...</Component>)
text = re.sub(r"<[A-Z]\w+\s*/>", "", text)
text = re.sub(r"<[A-Z]\w+[^>]*>.*?</[A-Z]\w+>", "", text, flags=re.DOTALL)
# <details>/<summary> → 태그 제거, 내용 유지
text = re.sub(r"<details>\s*", "", text)
text = re.sub(r"<summary[^>]*>(.+?)</summary>", r"[\1]", text)
text = re.sub(r"</details>", "", text)
# :::directive[제목] → ## 승격
text = re.sub(r":::(\w+)\[(.+?)\]", r"## \2", text)
text = re.sub(r"^:::\s*$", "", text, flags=re.MULTILINE)
# ## N. 제목 → ## 제목 (번호 제거)
text = re.sub(r"^## \d+\.\s*", "## ", text, flags=re.MULTILINE)
# ### N.N 제목 → ### 제목 (번호 제거)
text = re.sub(r"^### \d+\.\d+\s*", "### ", text, flags=re.MULTILINE)
# * **제목** → ## 승격 (## 전 도입부에서만)
first_hash = text.find("\n## ")
if first_hash == -1:
first_hash = len(text)
intro = text[:first_hash]
rest = text[first_hash:]
intro = re.sub(r"^\* \*\*(.+?)\*\*\s*$", r"## \1", intro, flags=re.MULTILINE)
text = intro + rest
# 이미지 참조 보존
text = re.sub(r"!\[(.+?)\]\((.+?)\)", r"[이미지: \1, 경로: \2]", text)
# 이탤릭 출처 (단독 줄)
text = re.sub(r"^\s*\*([^*\n]+)\*\s*$", r"출처: \1", text, flags=re.MULTILINE)
# 장식용 --- 제거
text = re.sub(r"^---\s*$", "", text, flags=re.MULTILINE)
# 연속 빈 줄 정리
text = re.sub(r"\n{3,}", "\n\n", text)
return text.strip()
def _slice_mdx_sections(content: str) -> dict[str, str]:
"""원본 MDX를 정규화 후 ## 기준으로 섹션별 슬라이싱.
0단계: normalize_mdx()로 MDX 표준화
1단계: ## 기준으로 분할
"""
# 0단계: MDX 정규화
normalized = normalize_mdx(content)
sections = {}
current_section = None
current_lines = []
for line in content.split("\n"):
for line in normalized.split("\n"):
if line.startswith("## "):
if current_section:
sections[current_section] = "\n".join(current_lines).strip()
@@ -591,12 +1023,12 @@ async def regenerate_area(
ref_spec = container_specs.get("첨부")
# 실제 zone 높이 계산
footer_h = container_specs.get("결론")
footer_h = footer_h.height_px if footer_h else 60
sidebar_zone_h = 720 - 80 - 66 - footer_h - 40
footer_h = footer_h.height_px if footer_h else 0
sidebar_zone_h = ref_spec.height_px if ref_spec else 0
defs = _get_definitions(content)
prompt = SIDEBAR_PROMPT.format(
width=ref_spec.width_px if ref_spec else 380,
prompt = _LEGACY_SIDEBAR_PROMPT.format(
width=ref_spec.width_px if ref_spec else 0,
height=sidebar_zone_h,
definitions_block=defs,
) + error_feedback
@@ -607,8 +1039,8 @@ async def regenerate_area(
elif area_name == "footer":
concl_spec = container_specs.get("결론")
footer_content = _get_conclusion(content)
prompt = FOOTER_PROMPT.format(
height=concl_spec.height_px if concl_spec else 60,
prompt = _LEGACY_FOOTER_PROMPT.format(
height=concl_spec.height_px if concl_spec else 0,
content_block=footer_content.strip(),
) + error_feedback
@@ -634,3 +1066,4 @@ def _replace_img_placeholder(html: str, images: list[dict]) -> str:
html = html.replace("src='placeholder'", f"src='{data_uri}'")
logger.info(f"[Phase S] 이미지 교체: {img_id}")
return html

View File

@@ -116,18 +116,22 @@ KEI_PROMPT_B = (
" - 예: '기술 융합/포함 관계. 순서 아님. 구성요소 간 관계 표현 필요.'\n"
" - 예: '수단-목적 관계. 대등 비교가 아님. 역할 차이를 보여줘야 함.'\n"
" - 예: '용어 3개의 독립적 정의. 나열.'\n\n"
"3. **원본 데이터 확인 (source_data)**:\n"
" - 원본에 비교표가 있는가? → 행/열 수, 활용 여부\n"
" - 원본에 사례/증거가 있는가? → 출처 명시\n"
" - 원본에 이미지가 있는가? → 역할\n"
" - 놓치면 안 되는 핵심 데이터가 있는가?\n\n"
"3. **원본 데이터 핵심 항목 (source_data)**:\n"
" - 이 꼭지에 해당하는 원본의 핵심 항목들을 나열하라.\n"
" - 항목이 여러 개면 '이름(설명), 이름(설명)' 형태로 쉼표 구분.\n"
" - 원본에 팝업이 참조되면 반드시 [팝업: 제목] 마커를 포함하라.\n"
" - 원본에 이미지가 참조되면 반드시 [이미지: 제목] 마커를 포함하라.\n"
" - 출처가 있으면 포함하라.\n"
" - '활용 필요', '구체화 필요' 같은 지시사항을 쓰지 마라. 실제 콘텐츠 항목만 쓰라.\n"
" - 예시: '건설산업(종합산업, 기술 통합 융합), BIM(정보관리 도구, 출처: 국토교통부 2020)'\n"
" - 예시: '[이미지: DX와 핵심기술간 상호관계] 다이어그램, GIS 역할(공간 분석). [팝업: DX와 BIM의 구분] 비교표'\n\n"
"## 출력 형식 (JSON만)\n"
"```json\n"
'{"concepts": ['
'{"topic_id": 1, '
'"relation_type": "inclusion|sequence|comparison|hierarchy|definition|cause_effect|none", '
'"expression_hint": "관계 성격 설명 (블록 이름 쓰지 말 것)", '
'"source_data": "원본에서 활용해야 할 데이터 설명"}]}\n'
'"source_data": "핵심 항목 나열 + [팝업:] [이미지:] 마커 + 출처"}]}\n'
"```\n\n"
)
@@ -173,11 +177,9 @@ async def refine_concepts(
async with httpx.AsyncClient(timeout=None) as client:
async with client.stream(
"POST",
f"{kei_url}/api/message",
f"{kei_url}/api/direct",
json={
"message": prompt,
"session_id": "design-agent-refine",
"mode_hint": "chat",
},
timeout=None,
) as response:
@@ -222,6 +224,104 @@ async def refine_concepts(
continue
KEI_STRUCTURED_TEXT_PROMPT = (
"아래는 슬라이드 스토리라인의 꼭지 목록과 원본 콘텐츠이다.\n"
"각 꼭지에 해당하는 원본 텍스트를 **슬라이드에 넣을 형태로 구조화**하라.\n\n"
"## 규칙\n"
"1. 원본 내용의 85% 이상을 보존하라. 축약하지 마라.\n"
"2. 각 문장을 불릿(•)으로 구분하라.\n"
"3. 하위 항목이 있으면 들여쓰기 불릿( •)으로 구분하라.\n"
"4. 출처가 있으면 반드시 포함하라 (출처: ...).\n"
"5. 개조식 어미로 변환하라 (~있다→~있음, ~한다→~함, ~이다→삭제).\n"
"6. 팝업 참조([팝업: ...])는 그대로 유지하라.\n"
"7. 이미지 참조([이미지: ...])는 그대로 유지하라.\n\n"
"## 출력 형식 (JSON만. 설명 없이.)\n"
"```json\n"
'{"structured_texts": ['
'{"topic_id": 1, '
'"structured_text": "• 첫 번째 문장\\n• 두 번째 문장\\n • 하위 항목"}]}\n'
"```\n\n"
)
async def generate_structured_text(
content: str,
analysis: dict[str, Any],
) -> dict[str, Any]:
"""1단계-B 보완: 각 꼭지의 structured_text를 생성.
refine_concepts() 후 별도 호출. 원본 텍스트를 85% 보존하여 구조화.
"""
import asyncio
topics = analysis.get("topics", [])
if not topics:
return analysis
topics_text = "\n".join(
f"- 꼭지 {t.get('id', '?')}: {t.get('title', '?')} "
f"[purpose: {t.get('purpose', '?')}, source_hint: {t.get('source_hint', '')}]"
for t in topics
)
prompt = (
KEI_STRUCTURED_TEXT_PROMPT
+ f"## 꼭지 목록\n{topics_text}\n\n"
+ f"## 원본 콘텐츠\n{content}\n"
)
kei_url = getattr(settings, "kei_api_url", "http://localhost:8000")
RETRY_INTERVAL = 10
attempt = 0
while True:
attempt += 1
try:
async with httpx.AsyncClient(timeout=None) as client:
async with client.stream(
"POST",
f"{kei_url}/api/direct",
json={"message": prompt},
timeout=None,
) as response:
if response.status_code != 200:
logger.warning(f"[1단계-B-ST] Kei API HTTP {response.status_code} (시도 {attempt})")
await asyncio.sleep(RETRY_INTERVAL)
continue
full_text = await stream_sse_tokens(response)
if not full_text:
logger.warning(f"[1단계-B-ST] 응답 텍스트 없음 (시도 {attempt})")
await asyncio.sleep(RETRY_INTERVAL)
continue
result = _parse_json(full_text)
if result and "structured_texts" in result:
st_map = {}
for st in result["structured_texts"]:
tid = st.get("topic_id") or st.get("id")
if tid is not None:
st_map[tid] = st.get("structured_text", "")
for topic in topics:
text = st_map.get(topic.get("id"), "")
if text:
topic["structured_text"] = text
filled = sum(1 for t in topics if t.get("structured_text"))
logger.info(f"[1단계-B-ST] structured_text 생성 완료: {filled}/{len(topics)}")
return analysis
else:
logger.warning(f"[1단계-B-ST] JSON 파싱 실패 (시도 {attempt}): {full_text[:200]}")
await asyncio.sleep(RETRY_INTERVAL)
continue
except Exception as e:
logger.warning(f"[1단계-B-ST] Kei API 실패 (시도 {attempt}): {e}")
await asyncio.sleep(RETRY_INTERVAL)
continue
async def _call_kei_api(content: str) -> dict[str, Any] | None:
"""Kei API를 통해 꼭지 추출. SSE 스트리밍으로 실시간 수신."""
kei_url = getattr(settings, "kei_api_url", "http://localhost:8000")
@@ -230,11 +330,9 @@ async def _call_kei_api(content: str) -> dict[str, Any] | None:
async with httpx.AsyncClient(timeout=None) as client:
async with client.stream(
"POST",
f"{kei_url}/api/message",
f"{kei_url}/api/direct",
json={
"message": KEI_PROMPT + content,
"session_id": "design-agent",
"mode_hint": "chat",
},
timeout=None,
) as response:
@@ -324,7 +422,7 @@ async def select_block_for_topics(
continue
spec = find_container_for_topic(tid, container_specs)
per_topic_px = spec.height_px // max(1, len(spec.topic_ids)) if spec else 200
per_topic_px = spec.height_px // max(1, len(spec.topic_ids)) if spec else 0
budget = budgets_per_topic.get(tid, {})
expression_hint = topic.get("expression_hint", "")
@@ -347,11 +445,9 @@ async def select_block_for_topics(
async with httpx.AsyncClient(timeout=None) as client:
async with client.stream(
"POST",
f"{kei_url}/api/message",
f"{kei_url}/api/direct",
json={
"message": full_prompt,
"session_id": "design-agent-q4",
"mode_hint": "chat",
},
timeout=None,
) as response:
@@ -450,7 +546,7 @@ async def vision_quality_gate(
client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
response = await client.messages.create(
model="claude-opus-4-0-20250514",
model="claude-opus-4-6-20250415",
max_tokens=2048,
messages=[{
"role": "user",
@@ -568,7 +664,7 @@ async def call_kei_final_review(
try:
client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
response = await client.messages.create(
model="claude-opus-4-0-20250514",
model="claude-opus-4-6-20250415",
max_tokens=4096,
system=KEI_REVIEW_PROMPT,
messages=[{
@@ -615,11 +711,9 @@ async def call_kei_final_review(
async with httpx.AsyncClient(timeout=None) as client:
async with client.stream(
"POST",
f"{kei_url}/api/message",
f"{kei_url}/api/direct",
json={
"message": prompt,
"session_id": "design-agent-final-review",
"mode_hint": "chat",
},
timeout=None,
) as response:
@@ -717,11 +811,9 @@ async def call_kei_overflow_judgment(
async with httpx.AsyncClient(timeout=None) as client:
async with client.stream(
"POST",
f"{kei_url}/api/message",
f"{kei_url}/api/direct",
json={
"message": prompt,
"session_id": "design-agent-overflow",
"mode_hint": "chat",
},
timeout=None,
) as response:
@@ -870,7 +962,7 @@ async def select_best_candidate(
try:
client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
response = await client.messages.create(
model="claude-opus-4-0-20250514",
model="claude-opus-4-6-20250415",
max_tokens=2048,
messages=[{"role": "user", "content": content_blocks}],
)
@@ -890,3 +982,435 @@ async def select_best_candidate(
except Exception as e:
logger.warning(f"[Phase P] Kei 최종 선택 실패: {e}")
return {"selections": []}
# ──────────────────────────────────────
# Phase V: 콘텐츠-컨테이너 적합성 에스컬레이션
# ──────────────────────────────────────
KEI_ENHANCEMENT_REVIEW_PROMPT = """당신은 슬라이드 설계 전문가이다.
아래는 슬라이드 콘텐츠 품질 강화를 위한 제안 목록이다.
각 제안을 검토하고, 승인/수정/거부를 결정하라.
## 판단 기준
- 핵심 메시지가 시각적으로 강조되는가?
- 빈 공간에 유의미한 콘텐츠를 추가할 수 있는가?
- 종속 꼭지의 처리 방식(인라인/하위블록)이 적절한가?
- bold 키워드가 핵심 용어인가?
## 출력 (JSON만. 설명 없이.)
```json
{
"decisions": [
{
"type": "subordinate|fill_space|emphasis|bold_keywords",
"role": "배경|본심|첨부|결론",
"action": "approve|modify|reject",
"modification": "수정 시 구체적 내용 (approve/reject면 빈 문자열)"
}
]
}
```
"""
async def call_kei_enhancement_review(
enhancement_report: str,
topics: list[dict],
core_message: str,
) -> dict[str, Any] | None:
"""Stage 1.8 Step 5: Kei에게 보강 제안을 보여주고 승인/수정/거부 결정을 받는다.
Kei API(/api/direct)만 사용.
"""
import asyncio
topics_text = "\n".join(
f"- 꼭지{t.get('id', '?')}: {t.get('title', '')} [{t.get('purpose', '')}]"
for t in topics
)
prompt = (
KEI_ENHANCEMENT_REVIEW_PROMPT + "\n\n"
f"## 핵심 메시지\n{core_message}\n\n"
f"## 꼭지 목록\n{topics_text}\n\n"
f"## 보강 제안\n{enhancement_report}\n"
)
kei_url = getattr(settings, "kei_api_url", "http://localhost:8000")
RETRY_INTERVAL = 10
attempt = 0
while True:
attempt += 1
try:
async with httpx.AsyncClient(timeout=None) as client:
async with client.stream(
"POST",
f"{kei_url}/api/direct",
json={"message": prompt},
timeout=None,
) as response:
if response.status_code != 200:
logger.warning(f"[V-5 Kei 보강 검토] HTTP {response.status_code} (시도 {attempt})")
await asyncio.sleep(RETRY_INTERVAL)
continue
full_text = await stream_sse_tokens(response)
if not full_text:
logger.warning(f"[V-5 Kei 보강 검토] 응답 없음 (시도 {attempt})")
await asyncio.sleep(RETRY_INTERVAL)
continue
result = _parse_json(full_text)
if result and "decisions" in result:
approved = sum(1 for d in result["decisions"] if d.get("action") == "approve")
total = len(result["decisions"])
logger.info(f"[V-5 Kei 보강 검토] {approved}/{total} 승인")
return result
else:
logger.warning(f"[V-5 Kei 보강 검토] JSON 파싱 실패 (시도 {attempt})")
await asyncio.sleep(RETRY_INTERVAL)
continue
except Exception as e:
logger.warning(f"[V-5 Kei 보강 검토] 실패 (시도 {attempt}): {e}")
await asyncio.sleep(RETRY_INTERVAL)
continue
async def call_kei_summarize_popup(
popup_title: str,
popup_content: str,
available_width_px: float,
available_height_px: float,
font_size: float,
) -> dict[str, Any] | None:
"""V'-2: 코드가 형태+크기를 결정하고, Kei가 텍스트만 채운다.
1. 코드: 팝업 원본에 표(|)가 있으면 table, 불릿(•)이면 bullets, 그 외 text
2. 코드: 공간 크기와 폰트를 고려하여 행/열 수 계산
3. Kei: 결정된 형태+크기에 맞게 원본 내용을 요약
Returns:
{
"format": "table" | "bullets" | "text",
"columns": [...], "data": [["", ...], ...], # table
"items": ["...", ...], # bullets
"summary": "...", # text
}
"""
import asyncio
header_h = font_size * 1.5 + font_size * 0.6
row_h = font_size * 1.5 + font_size * 0.6
bullet_h = font_size * 1.55
chars_per_col = int(available_width_px / (font_size * 0.6))
# 코드가 형태 판단
import re
has_table = popup_content.count("|") > 6 or "<table" in popup_content
has_bullets = popup_content.count("") > 2
if has_table:
# 원본 표에서 열 수 추출 — <th> 태그 우선, 없으면 | 파싱
th_headers = re.findall(r'<th[^>]*>(.*?)</th>', popup_content)
# <strong> 등 태그 제거
th_headers = [re.sub(r'<[^>]+>', '', h).strip() for h in th_headers]
if th_headers:
# 첫 번째 <thead>의 열만 사용 (중복 테이블 헤더 제거)
orig_cols = th_headers[:3] if len(th_headers) > 3 else th_headers
col_count = len(orig_cols)
else:
table_lines = [l.strip() for l in popup_content.split("\n") if l.strip().startswith("|")]
if len(table_lines) >= 2:
orig_cols = [c.strip() for c in table_lines[0].split("|") if c.strip()]
col_count = len(orig_cols)
else:
orig_cols = []
col_count = 3
# 행 수: 공간에 맞게 계산 (제목행 제외, 데이터 행만)
space_rows = int((available_height_px - header_h) / row_h) if available_height_px > header_h else 1
# 원본 표 데이터 행 수
orig_data_rows = len(re.findall(r'<tr>', popup_content)) or len([l for l in popup_content.split("\n") if l.strip().startswith("|") and not l.strip().startswith("|--")]) - 1
orig_data_rows = max(1, orig_data_rows)
# 공간과 원본 중 작은 쪽, 최소 1 최대 5
max_rows = min(space_rows, orig_data_rows, 5)
max_rows = max(1, max_rows)
chars_per_col = int(available_width_px / col_count / (font_size * 0.6))
fmt = "table"
prompt_task = (
f"원본 표를 정확히 {col_count}× {max_rows}행으로 요약하라.\n"
f"열 이름: {orig_cols}\n"
f"data 배열에 정확히 {max_rows}개의 행을 넣어라. 1행만 넣지 마라.\n"
f"원본에서 가장 핵심적인 {max_rows}개 비교 항목(범위, S/W, 프로세스, 성과품, 활용 등)을 골라라.\n"
f"각 셀은 {chars_per_col}자 이내의 짧은 핵심 요약으로.\n"
f"JSON: {{\"columns\": [\"{orig_cols[0] if orig_cols else '열1'}\", ...], \"data\": [[\"\", ...], ...]}}"
)
elif has_bullets:
max_items = int(available_height_px / bullet_h)
max_items = max(1, max_items)
fmt = "bullets"
prompt_task = (
f"원본 불릿을 {max_items}개 이내로 요약하라.\n"
f"각 항목은 {chars_per_col}자 이내로.\n"
f"JSON: {{\"items\": [\"항목1\", ...]}}"
)
else:
max_lines = int(available_height_px / bullet_h)
fmt = "text"
prompt_task = (
f"원본을 {max_lines}줄 이내로 요약하라.\n"
f"JSON: {{\"summary\": \"요약 텍스트\"}}"
)
prompt = f"""당신은 슬라이드 콘텐츠 요약 전문가이다.
## 팝업 제목: {popup_title}
## 팝업 원본:
{popup_content}
## 요청
{prompt_task}
핵심만 남기되, 원본의 의미가 왜곡되지 않도록 하라. JSON만 응답하라."""
kei_url = getattr(settings, "kei_api_url", "http://localhost:8000")
attempt = 0
while attempt < 5:
attempt += 1
try:
async with httpx.AsyncClient(timeout=None) as client:
async with client.stream(
"POST",
f"{kei_url}/api/direct",
json={"message": prompt},
timeout=None,
) as response:
if response.status_code != 200:
logger.warning(f"[V'-2 Kei 요약] HTTP {response.status_code} (시도 {attempt})")
await asyncio.sleep(10)
continue
full_text = await stream_sse_tokens(response)
if not full_text:
await asyncio.sleep(10)
continue
result = _parse_json(full_text)
if result and isinstance(result, dict):
# 코드가 결정한 format을 주입 (Kei는 텍스트만 채움)
result["format"] = fmt
logger.info(f"[V'-2 Kei 요약] {popup_title}: format={fmt}")
return result
else:
logger.warning(f"[V'-2 Kei 요약] 파싱 실패 (시도 {attempt})")
await asyncio.sleep(10)
continue
except Exception as e:
logger.warning(f"[V'-2 Kei 요약] 실패 (시도 {attempt}): {e}")
await asyncio.sleep(10)
continue
return None
async def call_kei_bold_keywords(
topics: list[dict],
page_structure: dict,
) -> dict[str, list[str]]:
"""V-10: Kei가 문맥 기반으로 각 역할의 bold 키워드를 판단한다.
Returns:
{"배경": ["키워드1", ...], "본심": [...], ...}
"""
import asyncio
# 역할별 structured_text 정리
topic_map = {t.get("id"): t for t in topics}
role_texts = {}
for role, info in page_structure.items():
if not isinstance(info, dict):
continue
tids = info.get("topic_ids", [])
texts = []
for tid in tids:
topic = topic_map.get(tid, {})
st = topic.get("structured_text", "") or topic.get("source_data", "")
if st:
texts.append(f"[{topic.get('title', '')}]\n{st}")
if texts:
role_texts[role] = "\n".join(texts)
if not role_texts:
return {}
role_section = "\n\n".join(
f"## {role}\n{text}" for role, text in role_texts.items()
)
prompt = f"""당신은 슬라이드 디자인 전문가이다.
아래는 슬라이드의 각 영역별 콘텐츠이다. 각 영역에서 **문맥상 정말 강조되어야 할 키워드**를 골라라.
규칙:
- 개수를 정하지 마라. 문맥에 맞게 필요한 만큼만 골라라.
- 단순 명사 나열이 아니라, 읽는 사람이 "이것이 핵심이구나"라고 느낄 키워드여야 한다.
- 일반적인 단어(역할, 기술, 정의 등)는 강조 대상이 아니다.
- 고유명사, 핵심 개념명, 대비되는 용어 등이 강조 대상이다.
JSON으로 응답하라:
{{"배경": ["키워드1", ...], "본심": [...], "첨부": [...], "결론": [...]}}
빈 역할은 빈 리스트로.
{role_section}"""
kei_url = getattr(settings, "kei_api_url", "http://localhost:8000")
RETRY_INTERVAL = 10
attempt = 0
while attempt < 5:
attempt += 1
try:
async with httpx.AsyncClient(timeout=None) as client:
async with client.stream(
"POST",
f"{kei_url}/api/direct",
json={"message": prompt},
timeout=None,
) as response:
if response.status_code != 200:
logger.warning(f"[V-10 Kei bold] HTTP {response.status_code} (시도 {attempt})")
await asyncio.sleep(RETRY_INTERVAL)
continue
full_text = await stream_sse_tokens(response)
if not full_text:
await asyncio.sleep(RETRY_INTERVAL)
continue
result = _parse_json(full_text)
if result and isinstance(result, dict):
logger.info(f"[V-10 Kei bold] 결과: {result}")
return result
else:
logger.warning(f"[V-10 Kei bold] 파싱 실패 (시도 {attempt})")
await asyncio.sleep(RETRY_INTERVAL)
continue
except Exception as e:
logger.warning(f"[V-10 Kei bold] 실패 (시도 {attempt}): {e}")
await asyncio.sleep(RETRY_INTERVAL)
continue
logger.warning("[V-10 Kei bold] 최대 재시도 초과, 빈 결과 반환")
return {}
KEI_FIT_ESCALATION_PROMPT = """당신은 슬라이드 설계 전문가이다.
콘텐츠를 컨테이너에 배치하려 했으나, 일부 영역의 콘텐츠가 공간을 초과한다.
재배분을 시도했지만 해결되지 않은 영역이 있다.
콘텐츠의 중요도와 전달 메시지를 기준으로, 어떻게 처리할지 결정하라.
## 판단 기준
- 핵심 메시지(본심)의 공간은 최대한 보장
- 배경은 보조 역할 — 간결화 가능
- 사례/근거는 인라인 축약 또는 팝업 분리 가능
- 용어 정의는 sidebar에 맞게 조정 가능
## 출력 (JSON만. 설명 없이.)
```json
{
"decisions": [
{
"role": "배경",
"action": "merge|inline|popup|trim|restructure",
"detail": "구체적 지시 (어떤 꼭지를 어떻게)",
"reason": "판단 근거 1문장"
}
]
}
```
action 종류:
- merge: 여러 꼭지를 하나의 블록 안에서 흐름으로 합침
- inline: 사례/근거를 괄호 한 줄로 축약하여 인라인
- popup: 상세 내용을 팝업으로 분리하고 링크만 남김
- trim: 텍스트 분량을 줄임 (max_chars 지정)
- restructure: 컨테이너 구조 자체를 변경 (배경 전체폭 등)
"""
async def call_kei_fit_escalation(
fit_report: str,
topics: list[dict],
content_summary: str,
) -> dict[str, Any] | None:
"""Phase V: 적합성 검증 실패 시 Kei에게 판단 요청.
Kei API만 사용. Anthropic 직접 호출 절대 금지.
"""
kei_url = getattr(settings, "kei_api_url", "http://localhost:8000")
topics_desc = json.dumps(
[
{
"id": t.get("id"),
"title": t.get("title", ""),
"purpose": t.get("purpose", ""),
"source_data": t.get("source_data", "")[:200],
}
for t in topics
],
ensure_ascii=False,
indent=2,
)
prompt = (
KEI_FIT_ESCALATION_PROMPT + "\n\n"
f"## 적합성 검증 결과\n{fit_report}\n\n"
f"## 꼭지 목록\n{topics_desc}\n\n"
f"## 원본 콘텐츠 요약\n{content_summary[:1500]}"
)
try:
async with httpx.AsyncClient(timeout=None) as client:
async with client.stream(
"POST",
f"{kei_url}/api/direct",
json={
"message": prompt,
},
timeout=None,
) as response:
if response.status_code != 200:
logger.warning(f"Kei API (fit) HTTP {response.status_code}")
return None
full_text = await stream_sse_tokens(response)
if full_text:
result = _parse_json(full_text)
if result and "decisions" in result:
logger.info(
f"[V-4] Kei 적합성 판단: "
+ ", ".join(
f"{d['role']}{d['action']}"
for d in result["decisions"]
)
)
return result
logger.warning("[V-4] Kei 적합성 판단 JSON 파싱 실패")
return None
logger.warning("Kei API (fit) 텍스트 추출 실패")
return None
except Exception as e:
logger.warning(f"Kei API (fit) 호출 실패: {e}")
return None

434
src/mdx_normalizer.py Normal file
View File

@@ -0,0 +1,434 @@
"""Phase T-1: MDX 4-Layer 파서.
Stage 0에서 호출. 원본 MDX를 정규화하여 이후 모든 Stage에 깨끗한 입력 제공.
Layer 1: python-frontmatter — YAML frontmatter 분리, title 추출
Layer 2: regex — 코드블록 보호 + MDX 전용 패턴 (details, :::, JSX, import)
Layer 3: markdown-it-py — AST 파싱 → 이미지/표/헤딩 구조 추출
Layer 4: regex — 텍스트 정리, 빈 줄 정리, clean_text
조사 결과 (T-1):
- python-frontmatter: parse() → (dict, str). frontmatter 없으면 안전하게 {}
- markdown-it-py: js-default 프리셋에 table 기본 포함. 한국어 정상
- 코드블록 보호: backtick 10→3 순서 매칭. 중첩/inline 검증됨
"""
from __future__ import annotations
import re
import logging
from typing import Any
import frontmatter
from markdown_it import MarkdownIt
logger = logging.getLogger(__name__)
# ══════════════════════════════════════
# 코드블록 보호 (Layer 2 선행)
# ══════════════════════════════════════
class _CodeBlockProtector:
"""코드블록을 placeholder로 보호하고 복원.
backtick 개수가 많은 순서(10→3)로 매칭하여 중첩 코드블록 안전 처리.
"""
def __init__(self):
self._store: dict[str, str] = {}
self._counter = 0
def _make_key(self) -> str:
self._counter += 1
return f"__CODEBLOCK_{self._counter}__"
def protect(self, text: str) -> str:
# fenced code blocks (큰 backtick부터)
for n in range(10, 2, -1):
pattern = rf"^(`{{{n}}})([^\n]*)\n(.*?)\n\1\s*$"
def _replacer(m, _n=n):
key = self._make_key()
self._store[key] = m.group(0)
return key
text = re.sub(pattern, _replacer, text, flags=re.MULTILINE | re.DOTALL)
# inline code
def _inline_replacer(m):
key = self._make_key()
self._store[key] = m.group(0)
return key
text = re.sub(r"`[^`\n]+`", _inline_replacer, text)
return text
def restore(self, text: str) -> str:
for key, original in self._store.items():
text = text.replace(key, original)
return text
# ══════════════════════════════════════
# Layer 2: MDX 전용 패턴 처리
# ══════════════════════════════════════
def _convert_md_table_to_html(text: str) -> str:
"""마크다운 테이블(| col | col |)을 HTML <table>로 변환.
어떤 마크다운 테이블이든 동작. 하드코딩 없음.
"""
lines = text.split("\n")
result = []
table_lines = []
in_table = False
for line in lines:
stripped = line.strip()
if stripped.startswith("|") and stripped.endswith("|"):
table_lines.append(stripped)
in_table = True
else:
if in_table and table_lines:
result.append(_render_md_table(table_lines))
table_lines = []
in_table = False
result.append(line)
if table_lines:
result.append(_render_md_table(table_lines))
return "\n".join(result)
def _render_md_table(table_lines: list[str]) -> str:
"""마크다운 테이블 라인들을 HTML 테이블로."""
if len(table_lines) < 2:
return "\n".join(table_lines)
def _parse_row(line):
cells = [c.strip() for c in line.split("|")]
# 앞뒤 빈 셀 제거 (| col1 | col2 | → ['', 'col1', 'col2', ''])
return [c for c in cells if c or c == ""].__getitem__(slice(1, -1)) if cells[0] == "" else cells
headers = _parse_row(table_lines[0])
# 구분선(|---|---|) 스킵
data_start = 1
if len(table_lines) > 1 and all(c.strip().replace("-", "").replace(":", "") == "" for c in table_lines[1].split("|") if c.strip()):
data_start = 2
rows = [_parse_row(line) for line in table_lines[data_start:]]
# HTML 생성
header_html = "".join(f"<th>{h}</th>" for h in headers)
rows_html = ""
for row in rows:
cells = "".join(f"<td>{c}</td>" for c in row)
rows_html += f"<tr>{cells}</tr>\n"
return f"<table><thead><tr>{header_html}</tr></thead><tbody>{rows_html}</tbody></table>"
def _process_mdx_patterns(text: str) -> tuple[str, list[dict]]:
"""MDX 전용 패턴 처리. popups를 추출하고 텍스트에서 마커로 교체.
Returns:
(처리된 텍스트, popups 리스트)
"""
popups = []
# <details><summary>제목</summary>내용</details> → 팝업 추출
def _extract_popup(m):
title = m.group(1).strip()
content = m.group(2).strip()
# 팝업 content 정화: JSX style 제거 + 마크다운 → HTML
content = re.sub(r"<div\s+style=\{\{[^}]*\}\}\s*>", "", content)
content = content.replace("</div>", "")
content = re.sub(r"<br\s*/?>", "\n", content)
content = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", content)
# 마크다운 테이블 → HTML 테이블
content = _convert_md_table_to_html(content)
popups.append({"title": title, "content": content})
return f"[팝업: {title}]"
text = re.sub(
r"<details>\s*<summary[^>]*>(.+?)</summary>(.*?)</details>",
_extract_popup,
text,
flags=re.DOTALL,
)
# import 문 제거
text = re.sub(r"^import\s+.+$", "", text, flags=re.MULTILINE)
text = re.sub(r"^export\s+.+$", "", text, flags=re.MULTILINE)
# <br/> 제거
text = re.sub(r"<br\s*/?>", "", text)
# JSX div style → 태그만 제거 (내용 유지)
text = re.sub(r"<div\s+style=\{\{[^}]*\}\}\s*>", "", text)
text = text.replace("</div>", "")
# 커스텀 컴포넌트 (<Component />, <Component>...</Component>)
text = re.sub(r"<[A-Z]\w+\s*/>", "", text)
text = re.sub(r"<[A-Z]\w+[^>]*>.*?</[A-Z]\w+>", "", text, flags=re.DOTALL)
# :::directive[제목] → ## 승격 + 핵심요약 마킹
def _process_directive(m):
directive = m.group(1)
title = m.group(2)
if directive in ("note", "tip", "caution", "danger"):
return f"[핵심요약: {title}]"
return f"## {title}"
text = re.sub(r":::(\w+)\[(.+?)\]", _process_directive, text)
text = re.sub(r"^:::\s*$", "", text, flags=re.MULTILINE)
# ## N. 제목 → ## 제목 (번호 제거, 공백 1개 이상 필수 — T-1 조사 버그 수정)
text = re.sub(r"^## \d+\.\s+", "## ", text, flags=re.MULTILINE)
# ### N.N 제목 → ### 제목
text = re.sub(r"^### \d+\.\d+\s+", "### ", text, flags=re.MULTILINE)
# * **제목** → ## 승격 (## 전 도입부에서만)
first_hash = text.find("\n## ")
if first_hash == -1:
first_hash = len(text)
intro = text[:first_hash]
rest = text[first_hash:]
intro = re.sub(r"^\* \*\*(.+?)\*\*\s*$", r"## \1", intro, flags=re.MULTILINE)
text = intro + rest
# 이탤릭 출처 (단독 줄)
text = re.sub(r"^\s*\*([^*\n]+)\*\s*$", r"출처: \1", text, flags=re.MULTILINE)
# 장식용 --- 제거
text = re.sub(r"^---\s*$", "", text, flags=re.MULTILINE)
return text, popups
# ══════════════════════════════════════
# Layer 3: AST 파싱
# ══════════════════════════════════════
def _extract_structure(text: str) -> dict[str, Any]:
"""markdown-it-py AST 파싱으로 구조 추출.
Returns:
{"images": [...], "tables": [...], "sections": [...]}
"""
md = MarkdownIt("js-default")
tokens = md.parse(text)
images = []
tables = []
sections = []
current_section_title = ""
current_section_lines = []
def _flush_section():
nonlocal current_section_title, current_section_lines
if current_section_title:
sections.append({
"level": 2,
"title": current_section_title,
"content": "\n".join(current_section_lines).strip(),
})
current_section_lines = []
for i, token in enumerate(tokens):
# 이미지 추출 (inline children)
if token.type == "inline" and token.children:
for child in token.children:
if child.type == "image":
attrs = child.attrs or {}
images.append({
"alt": child.content or attrs.get("alt", ""),
"path": attrs.get("src", ""),
})
# 표 추출
if token.type == "table_open":
table = {"headers": [], "rows": []}
# 이후 토큰에서 thead/tbody 파싱
j = i + 1
in_thead = False
in_tbody = False
current_row = []
while j < len(tokens) and tokens[j].type != "table_close":
t = tokens[j]
if t.type == "thead_open":
in_thead = True
elif t.type == "thead_close":
in_thead = False
if current_row:
table["headers"] = current_row
current_row = []
elif t.type == "tbody_open":
in_tbody = True
elif t.type == "tbody_close":
in_tbody = False
elif t.type == "tr_close":
if in_tbody and current_row:
table["rows"].append(current_row)
elif in_thead and current_row:
table["headers"] = current_row
current_row = []
elif t.type == "inline" and (in_thead or in_tbody):
current_row.append(t.content)
j += 1
if table["headers"] or table["rows"]:
tables.append(table)
# 섹션 추출 (## 기준)
if token.type == "heading_open" and token.tag == "h2":
_flush_section()
# 다음 토큰이 inline (제목 텍스트)
if i + 1 < len(tokens) and tokens[i + 1].type == "inline":
current_section_title = tokens[i + 1].content
elif current_section_title and token.type in ("paragraph_open", "bullet_list_open",
"ordered_list_open", "fence"):
# 섹션 내용 수집 — inline 토큰의 content만
pass
if current_section_title and token.type == "inline" and token.tag == "":
# heading의 inline은 제목이므로 건너뜀 (이미 current_section_title에 저장)
parent_type = tokens[i - 1].type if i > 0 else ""
if parent_type != "heading_open":
current_section_lines.append(token.content)
_flush_section()
return {"images": images, "tables": tables, "sections": sections}
# ══════════════════════════════════════
# Layer 4: 텍스트 정리
# ══════════════════════════════════════
def _clean_text(text: str) -> str:
"""최종 텍스트 정리: 남은 HTML 태그 제거, 빈 줄 정리."""
# 이미지 참조 보존 (markdown 형식 → 마커)
text = re.sub(r"!\[(.+?)\]\((.+?)\)", r"[이미지: \1]", text)
# 남은 HTML 태그 제거 (self-closing)
text = re.sub(r"<[^>]+/?>", "", text)
# 연속 빈 줄 정리
text = re.sub(r"\n{3,}", "\n\n", text)
return text.strip()
# ══════════════════════════════════════
# 메인 함수
# ══════════════════════════════════════
def normalize_mdx_content(raw_mdx: str) -> dict[str, Any]:
"""MDX 원본을 4-Layer 파서로 정규화.
Stage 0에서 호출. 결과는 PipelineContext.normalized에 저장.
Returns:
{
"clean_text": str,
"title": str,
"images": [{"alt": str, "path": str}],
"popups": [{"title": str, "content": str}],
"tables": [{"headers": list, "rows": list}],
"sections": [{"level": int, "title": str, "content": str}],
}
"""
# ── Layer 1: frontmatter 분리 ──
metadata, body = frontmatter.parse(raw_mdx)
title = metadata.get("title", "")
logger.info(f"[Layer 1] title='{title}', metadata keys={list(metadata.keys())}")
# ── Layer 2: 코드블록 보호 → MDX 패턴 처리 ──
protector = _CodeBlockProtector()
protected = protector.protect(body)
processed, popups = _process_mdx_patterns(protected)
restored = protector.restore(processed)
logger.info(f"[Layer 2] popups={len(popups)}개, 코드블록={protector._counter}개 보호/복원")
# ── Layer 3: AST 파싱 → 구조 추출 ──
structure = _extract_structure(restored)
images = structure["images"]
tables = structure["tables"]
sections = structure["sections"]
logger.info(f"[Layer 3] images={len(images)}, tables={len(tables)}, sections={len(sections)}")
# ── Layer 4: 텍스트 정리 ──
clean_text = _clean_text(restored)
logger.info(f"[Layer 4] clean_text={len(clean_text)}")
return {
"clean_text": clean_text,
"title": title,
"images": images,
"popups": popups,
"tables": tables,
"sections": sections,
}
# ══════════════════════════════════════
# Stage 0 검증
# ══════════════════════════════════════
def validate_stage0(result: dict, raw_mdx: str) -> list[dict]:
"""Stage 0 출력 검증.
Returns:
에러 리스트 (빈 리스트 = 통과)
"""
errors = []
clean_text = result.get("clean_text", "")
if not clean_text.strip():
errors.append({
"severity": "FATAL",
"field": "clean_text",
"localization": "clean_text가 비어있음",
"instruction": "원본 MDX를 확인하세요",
})
return errors
# 원본 대비 텍스트 보존율 (30% 이상)
raw_text_len = len(re.sub(r"<[^>]+>|\{[^}]+\}|---\n.*?\n---", "", raw_mdx, flags=re.DOTALL).strip())
if raw_text_len > 0:
preservation = len(clean_text) / raw_text_len
if preservation < 0.3:
errors.append({
"severity": "FATAL",
"field": "clean_text",
"localization": f"텍스트 보존율 {preservation:.0%} < 30%",
"evidence": f"원본 {raw_text_len}자 → clean {len(clean_text)}",
"instruction": "파서가 너무 많은 텍스트를 제거함",
})
# 이미지 수 대조
raw_img_count = len(re.findall(r"!\[", raw_mdx))
result_img_count = len(result.get("images", []))
if raw_img_count > 0 and result_img_count == 0:
errors.append({
"severity": "ADJUSTABLE",
"field": "images",
"localization": f"원본 이미지 {raw_img_count}개, 추출 0개",
"instruction": "이미지 추출 패턴 확인",
})
# 팝업 수 대조
raw_details_count = raw_mdx.count("<details>")
result_popup_count = len(result.get("popups", []))
if raw_details_count > 0 and result_popup_count == 0:
errors.append({
"severity": "ADJUSTABLE",
"field": "popups",
"localization": f"원본 details {raw_details_count}개, 추출 0개",
"instruction": "details 추출 패턴 확인",
})
return errors

File diff suppressed because it is too large Load Diff

316
src/pipeline_context.py Normal file
View File

@@ -0,0 +1,316 @@
"""Phase T-0: 파이프라인 누적 컨텍스트 객체.
모든 Stage가 하나의 PipelineContext를 공유하며,
각 Stage가 transform → validate → update 패턴을 따른다.
Pydantic BaseModel 채택 이유 (T-0 조사 결과):
- model_dump_json()으로 스냅샷 직렬화 한 줄
- validate_assignment=True로 타입 오류 즉시 감지
- Path, Optional, list[dict] 자동 처리
- 프로젝트가 이미 Pydantic 사용 중 (config.py, FastAPI)
"""
from __future__ import annotations
import json
import time
from pathlib import Path
from typing import Any, Optional
from pydantic import BaseModel, Field, model_validator
# ──────────────────────────────────────
# 하위 모델
# ──────────────────────────────────────
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)
tables: list[dict[str, Any]] = Field(default_factory=list)
sections: list[dict[str, Any]] = Field(default_factory=list)
class Topic(BaseModel):
"""Stage 1A + 1B 출력: 개별 꼭지 정보.
weight는 여기에 없음 — page_structure의 역할별 속성임.
"""
id: int = 0
title: str = ""
purpose: str = ""
role: str = ""
layer: str = ""
source_hint: str = ""
# Stage 1B에서 병합
relation_type: str = "" # 7개 enum: hierarchy/cause_effect/comparison/sequence/definition/inclusion/none
expression_hint: str = ""
source_data: str = ""
structured_text: str = "" # Stage 1B: 원본 85% 보존 구조화 텍스트 (조립용)
summary: str = ""
class PageStructure(BaseModel):
"""Stage 1A 출력: 역할별 비중 구조."""
roles: dict[str, dict[str, Any]] = Field(default_factory=dict)
# 예: {"본심": {"topic_ids": [1,2], "weight": 0.6}, "배경": {...}, ...}
class Analysis(BaseModel):
"""Stage 1A 출력: Kei 분석 결과 전체."""
core_message: str = ""
title: str = ""
total_pages: int = 1
image_sizes: dict[str, dict[str, Any]] = Field(default_factory=dict)
# topics와 page_structure는 PipelineContext 최상위에 위치
class TextBudget(BaseModel):
"""Stage 1.5a 출력: 텍스트 예산."""
font_size: float = 12.0
chars_per_line: int = 0
max_lines: int = 0
max_chars: int = 0
source_chars: int = 0
needs_compression: bool = False
class DesignBudget(BaseModel):
"""Stage 1.5b 출력: 디자인 요소 예산."""
available_height_px: int = 0
available_width_px: int = 0
max_circle_diameter: int = 0
max_img_width: int = 0
max_img_height: int = 0
fits: bool = True
class ContainerInfo(BaseModel):
"""Stage 1.5a/1.5b 통합: 역할별 컨테이너 정보."""
role: str = ""
zone: str = ""
topic_ids: list[int] = Field(default_factory=list)
weight: float = 0.0
height_px: int = 0
width_px: int = 0
max_height_cost: str = "medium"
text_budget: Optional[TextBudget] = None
design_budget: Optional[DesignBudget] = None
block_constraints: dict[str, Any] = Field(default_factory=dict)
class FontHierarchy(BaseModel):
"""Stage 1.5a 출력: 확정된 폰트 위계."""
key_msg: float = 14.0 # 핵심 메시지 (가장 큼)
core: float = 12.0 # 본문
bg: float = 11.0 # 배경 (10-12 범위)
sidebar: float = 10.0 # 첨부 (9-11 범위)
@model_validator(mode="after")
def check_hierarchy(self):
"""폰트 위계 유지 검증: key_msg > core >= bg > sidebar."""
if not (self.key_msg > self.core >= self.bg > self.sidebar):
raise ValueError(
f"폰트 위계 위반: key_msg({self.key_msg}) > core({self.core}) "
f">= bg({self.bg}) > sidebar({self.sidebar}) 이어야 함"
)
return self
class BlockReference(BaseModel):
"""Stage 1.7 출력: 참고 블록 정보."""
block_id: str = ""
variant: str = "default"
visual_type: str = ""
schema_info: dict[str, Any] = Field(default_factory=dict)
design_reference_html: str = ""
topic_id: int | None = None
supporting_topic_ids: list[int] = Field(default_factory=list)
is_hierarchical: bool = False
class StageError(BaseModel):
"""Stage 실행 중 발생한 에러."""
stage: str = ""
attempt: int = 0
severity: str = "RETRYABLE" # FATAL / RETRYABLE / ADJUSTABLE
errors: list[dict[str, Any]] = Field(default_factory=list)
# ──────────────────────────────────────
# 메인 컨텍스트
# ──────────────────────────────────────
class PipelineContext(BaseModel):
"""파이프라인 전체를 관통하는 누적 컨텍스트.
각 Stage가 이 객체를 받아서 필요한 필드를 읽고,
결과를 model_copy(update=...)로 병합한다.
"""
model_config = {"validate_assignment": True, "arbitrary_types_allowed": True}
# ── 메타 ──
run_id: str = ""
run_dir: Optional[str] = None # Path를 str로 저장 (JSON 직렬화)
raw_content: str = "" # 원본 MDX (변경 불가 참조용)
base_path: str = "" # 이미지 기준 경로
# ── Stage 0 ──
normalized: NormalizedContent = Field(default_factory=NormalizedContent)
# ── Stage 1A ──
analysis: Analysis = Field(default_factory=Analysis)
topics: list[Topic] = Field(default_factory=list)
page_structure: PageStructure = Field(default_factory=PageStructure)
# ── Stage 1.5a ──
font_hierarchy: FontHierarchy = Field(default_factory=FontHierarchy)
container_ratio: tuple[int, int] = (0, 0) # Stage 1.5a에서 설정 (body_pct, sidebar_pct)
containers: dict[str, ContainerInfo] = Field(default_factory=dict)
# ── Stage 1.7 ──
references: dict[str, list[BlockReference]] = Field(default_factory=dict)
preset_name: str = ""
preset: dict[str, Any] = Field(default_factory=dict)
# ── Stage 1.8 ──
fit_result: dict[str, Any] = Field(default_factory=dict)
enhancement_result: dict[str, Any] = Field(default_factory=dict)
sub_layouts: dict[str, Any] = Field(default_factory=dict) # role → ContainerLayout 직렬화
# ── Stage 2 ──
generated_html: dict[str, str] = Field(default_factory=dict) # body_html, sidebar_html, footer_html
# ── Stage 3 ──
rendered_html: str = ""
# ── Stage 4 ──
measurement: dict[str, Any] = Field(default_factory=dict)
quality_score: int = 0
screenshot_b64: str = ""
# ── 에러/경고 추적 ──
errors: list[StageError] = Field(default_factory=list)
warnings: list[str] = Field(default_factory=list)
retry_feedback: str = "" # 재시도 시 Self-Refine 피드백
# ── 이미지 ──
slide_images: list[dict[str, Any]] = Field(default_factory=list)
def get_run_dir(self) -> Path:
"""run_dir를 Path 객체로 반환."""
if self.run_dir:
return Path(self.run_dir)
p = Path("data/runs") / self.run_id
return p
def save_snapshot(self, stage_name: str) -> None:
"""디버깅용 스냅샷 저장. JSON + HTML 시각화."""
run_dir = self.get_run_dir()
run_dir.mkdir(parents=True, exist_ok=True)
# JSON
path = run_dir / f"{stage_name}_context.json"
path.write_text(
self.model_dump_json(indent=2, exclude={"screenshot_b64", "rendered_html"}),
encoding="utf-8",
)
# HTML 시각화
try:
from src.step_visualizer import generate_step_html
steps_dir = run_dir / "steps"
steps_dir.mkdir(exist_ok=True)
generate_step_html(stage_name, self, steps_dir)
except Exception as e:
pass # 시각화 실패해도 파이프라인은 계속
def log_error(self, stage: str, errors: list[dict], attempt: int = 0,
severity: str = "RETRYABLE") -> None:
"""에러를 컨텍스트에 기록."""
self.errors.append(StageError(
stage=stage,
attempt=attempt,
severity=severity,
errors=errors,
))
def get_role_content(self, role: str) -> str:
"""역할(본심/배경/첨부/결론)에 해당하는 원본 텍스트를 반환.
page_structure에서 topic_ids를 찾고,
해당 topics의 source_data를 합쳐서 반환.
source_data가 없으면 normalized.clean_text에서 source_hint로 매칭.
"""
role_info = self.page_structure.roles.get(role, {})
topic_ids = role_info.get("topic_ids", [])
texts = []
for t in self.topics:
if t.id in topic_ids:
if t.source_data:
texts.append(t.source_data)
elif t.source_hint and self.normalized.sections:
# source_hint로 섹션 매칭
for sec in self.normalized.sections:
if t.source_hint.lower() in sec.get("title", "").lower():
texts.append(sec.get("content", ""))
break
return "\n\n".join(texts) if texts else ""
# ──────────────────────────────────────
# Stage 실행 유틸리티
# ──────────────────────────────────────
class StageFailure(Exception):
"""Stage 실행 실패 (재시도 소진)."""
def __init__(self, stage_name: str, errors: list[dict]):
self.stage_name = stage_name
self.errors = errors
super().__init__(f"Stage {stage_name} 실패: {errors}")
def build_retry_feedback(stage_name: str, errors: list[dict],
original_text: str = "") -> str:
"""Self-Refine 패턴: localization + evidence + instruction.
NeurIPS 2023 Self-Refine + VASCAR Scorer/Suggester 분리 패턴.
"""
lines = [
f"## 이전 {stage_name} 결과의 검증 실패. 다음 문제를 수정하라.\n"
]
for i, err in enumerate(errors, 1):
lines.append(f"### 문제 {i}: {err.get('field', err.get('layer', ''))}")
if err.get("localization"):
lines.append(f"- 위치: {err['localization']}")
if err.get("current_value"):
lines.append(f"- 현재 값: {err['current_value']}")
if err.get("evidence"):
lines.append(f"- 원본 근거: \"{err['evidence']}\"")
if err.get("instruction"):
lines.append(f"- 수정 지시: {err['instruction']}")
lines.append("")
if original_text:
excerpt = original_text[:500]
lines.append(f"## 원본 텍스트 (참고)\n{excerpt}\n")
lines.append("위 문제들을 해결한 결과를 다시 생성하라. 원본에 없는 해석을 추가하지 마라.")
return "\n".join(lines)
def create_context(content: str, base_path: str = "") -> PipelineContext:
"""파이프라인 시작 시 초기 컨텍스트 생성."""
run_id = time.strftime("%Y%m%d_%H%M%S")
run_dir = str(Path("data/runs") / run_id)
return PipelineContext(
run_id=run_id,
run_dir=run_dir,
raw_content=content,
base_path=base_path,
)

View File

@@ -531,13 +531,79 @@ def render_slide_from_html(
title = analysis.get("title", "슬라이드")
grid_areas = preset.get("grid_areas", "'header header' 'body sidebar' 'footer footer'")
grid_columns = preset.get("grid_columns", "65fr 35fr")
grid_rows = preset.get("grid_rows", "auto 1fr auto")
# Phase T: 동적 비율 반영 — container_ratio가 있으면 프리셋 고정값 대신 사용
container_ratio = analysis.get("_container_ratio")
if container_ratio and len(container_ratio) == 2:
grid_columns = f"{container_ratio[0]}fr {container_ratio[1]}fr"
else:
grid_columns = preset.get("grid_columns", "65fr 35fr")
# Phase W: AFTER 컨테이너 크기를 grid-template-rows에 반영
# header=auto, body/sidebar=AFTER 높이에 맞춤, footer=AFTER 높이
containers = analysis.get("_containers", {})
redist = analysis.get("_fit_redistribution", {})
from src.fit_verifier import _load_design_tokens as _ldt
_tokens = _ldt()
_header_h = _tokens.get("header_height", 66)
_gap_small = _tokens["spacing_small"]
_bg_h = int(redist.get("배경", containers.get("배경", {}).get("height_px", 0)))
_core_h = int(redist.get("본심", containers.get("본심", {}).get("height_px", 0)))
_footer_h = int(redist.get("결론", containers.get("결론", {}).get("height_px", 0)))
_body_row_h = _bg_h + _core_h + _gap_small if _bg_h and _core_h else 0
if _body_row_h > 0 and _footer_h > 0:
grid_rows = f"auto {_body_row_h}px {_footer_h}px"
else:
grid_rows = preset.get("grid_rows", "auto auto auto").replace("1fr", "auto")
body_html = generated.get("body_html", "")
sidebar_html = generated.get("sidebar_html", "")
footer_html = generated.get("footer_html", "")
# ── 후처리 ──
import re as _re
# 1) sidebar 최외곽 wrapper div만 width:100% (grid cell에 맞추기)
# 첫 번째 태그의 style에서만 변경. 내부 요소(카드 번호 등)는 건드리지 않음.
sidebar_html = _re.sub(
r'^(\s*<div\s+style="[^"]*?)width:\s*\d+px',
r'\1width:100%',
sidebar_html,
count=1,
)
# 2) overflow-y:auto/scroll → overflow:hidden (스크롤 절대 금지)
body_html = _re.sub(r'overflow-y:\s*(auto|scroll)', 'overflow:hidden', body_html)
body_html = _re.sub(r'overflow:\s*(?:auto|scroll)(?!bar)', 'overflow:hidden', body_html)
sidebar_html = _re.sub(r'overflow-y:\s*(auto|scroll)', 'overflow:hidden', sidebar_html)
sidebar_html = _re.sub(r'overflow:\s*(?:auto|scroll)(?!bar)', 'overflow:hidden', sidebar_html)
# 3) markdown **bold** → <strong>
body_html = _re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', body_html)
sidebar_html = _re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', sidebar_html)
# 4) 폰트 위계 강제: 배경 영역 font-size가 본심(core)보다 크면 안 됨
font_h = analysis.get("_font_hierarchy", {})
bg_max = font_h.get("bg", 11.0)
core_max = font_h.get("core", 12.0)
sidebar_max = font_h.get("sidebar", 10.0)
def _cap_font(html_str: str, max_size: float) -> str:
"""font-size: NNpx 중 max_size 초과하는 것을 max_size로 캡."""
def _repl(m):
val = float(m.group(1))
if val > max_size:
return f"font-size:{max_size}px"
return m.group(0)
return _re.sub(r'font-size:\s*(\d+(?:\.\d+)?)px', _repl, html_str)
# body_html의 첫 번째 주요 div(배경)만 캡: height:117px or 배경 색상이 있는 div
# 배경 div 끝(</div> + spacer) 이후가 본심
bg_end = body_html.find('<div style="height:12px;') # spacer between bg and core
if bg_end > 0:
bg_part = body_html[:bg_end]
core_part = body_html[bg_end:]
bg_part = _cap_font(bg_part, bg_max)
body_html = bg_part + core_part
# sidebar 전체 캡
sidebar_html = _cap_font(sidebar_html, sidebar_max)
html = f"""<!DOCTYPE html>
<html lang="ko">
<head>

View File

@@ -21,14 +21,36 @@ logger = logging.getLogger(__name__)
# ──────────────────────────────────────
# height_cost → px 범위 매핑
# height_cost → px 범위 매핑: catalog.yaml의 블록들에서 동적 구축
# ──────────────────────────────────────
HEIGHT_COST_PX_RANGE = {
"compact": (30, 80),
"medium": (80, 200),
"large": (200, 350),
"xlarge": (350, 500),
}
_height_cost_cache: dict[str, tuple[int, int]] | None = None
def _get_height_cost_px_range() -> dict[str, tuple[int, int]]:
"""catalog.yaml의 블록 min_height_px에서 height_cost별 범위를 동적 계산."""
global _height_cost_cache
if _height_cost_cache is not None:
return _height_cost_cache
from src.block_reference import _load_catalog
# height_cost별로 min_height_px 수집
cost_heights: dict[str, list[int]] = {}
for b in _load_catalog():
cost = b.get("height_cost", "medium")
h = b.get("min_height_px", 0)
if cost not in cost_heights:
cost_heights[cost] = []
cost_heights[cost].append(h)
# 각 cost의 (min, max) 범위 계산
result = {}
for cost, heights in cost_heights.items():
if heights:
result[cost] = (min(heights), max(heights))
else:
result[cost] = (0, 0)
_height_cost_cache = result
return result
HEIGHT_COST_ORDER = {"compact": 0, "medium": 1, "large": 2, "xlarge": 3}
@@ -44,6 +66,229 @@ ROLE_ZONE_MAP = {
DEFAULT_FONT_SIZE_PX = 15.2
DEFAULT_LINE_HEIGHT = 1.7
DEFAULT_AVG_CHAR_WIDTH_PX = 14.4 # fonttools 실측 기반 (Pretendard 한글)
CHAR_WIDTH_RATIO = 0.947 # Pretendard 한글 실측: char_width = font_size × 0.947
# ──────────────────────────────────────
# Phase T-5: 폰트 위계 + 동적 비율 역산
# ──────────────────────────────────────
# 역할별 폰트 위계 범위 (min, max)
# 핵심 원칙: font_size(핵심) > font_size(본심) >= font_size(배경) > font_size(첨부)
FONT_HIERARCHY_RANGE: dict[str, tuple[float, float]] = {
"핵심": (14.0, 14.0), # 고정 14px bold
"본심": (12.0, 12.0), # 고정 12px
"배경": (10.0, 12.0), # 텍스트 양에 따라 조정
"첨부": (9.0, 11.0), # 텍스트 양에 따라 조정
"결론": (12.0, 14.0), # footer 배너용
}
# 역할별 줄 높이 비율
ROLE_LINE_HEIGHT: dict[str, float] = {
"핵심": 1.4,
"본심": 1.5,
"배경": 1.4,
"첨부": 1.4,
"결론": 1.3,
}
def _estimate_required_height(
text_chars: int,
font_size: float,
available_width: int,
line_height_ratio: float = 1.5,
padding: int | None = None,
) -> int:
"""주어진 폰트 크기로 텍스트를 넣으려면 몇 px 필요한가."""
from src.fit_verifier import _load_design_tokens
tokens = _load_design_tokens()
if padding is None:
padding = tokens["spacing_block"]
if text_chars <= 0:
return padding * 2
char_width = font_size * CHAR_WIDTH_RATIO
inner_width = max(tokens["spacing_page"], available_width - padding * 2)
chars_per_line = max(1, int(inner_width / char_width))
total_lines = max(1, -(-text_chars // chars_per_line)) # ceil division
line_height_px = font_size * line_height_ratio
return int(total_lines * line_height_px) + padding * 2
def calculate_font_hierarchy(
role_text_lengths: dict[str, int],
available_width: int | None = None,
) -> dict[str, float]:
"""역할별 폰트 크기를 위계 범위 내에서 텍스트 양 기반으로 확정.
Phase T 핵심: 위계가 먼저, 컨테이너가 따라간다.
Args:
role_text_lengths: {"본심": 500, "배경": 200, "첨부": 300, "결론": 50}
available_width: 예상 가용 너비 (px)
Returns:
{"핵심": 14.0, "본심": 12.0, "배경": 11.0, "첨부": 10.0, "결론": 13.0}
"""
if available_width is None:
from src.config import settings
from src.fit_verifier import _load_design_tokens
tokens = _load_design_tokens()
available_width = settings.slide_width - tokens["spacing_page"] * 2
result = {}
for role, (font_min, font_max) in FONT_HIERARCHY_RANGE.items():
text_len = role_text_lengths.get(role, 0)
if font_min == font_max:
# 고정 폰트 (핵심, 본심)
result[role] = font_max
continue
# 텍스트 양이 많으면 폰트 축소 (범위 내)
# max 폰트로 시도 → 안 되면 1px씩 축소
chosen = font_max
for fs in [font_max, font_max - 1, font_min]:
fs = max(font_min, fs)
required_h = _estimate_required_height(
text_len, fs, available_width, ROLE_LINE_HEIGHT.get(role, 1.5)
)
# 합리적 범위(xlarge 최대 높이 이내)면 이 폰트 사용
ranges = _get_height_cost_px_range()
max_reasonable_h = ranges.get("xlarge", (0, 0))[1] if ranges.get("xlarge") else required_h
if required_h <= max_reasonable_h:
chosen = fs
break
chosen = fs # 최소 폰트라도 사용
result[role] = chosen
# 위계 강제: 핵심 > 본심 >= 배경 > 첨부
if result.get("배경", 11) > result.get("본심", 12):
result["배경"] = result["본심"]
if result.get("첨부", 10) >= result.get("배경", 11):
result["첨부"] = max(FONT_HIERARCHY_RANGE["첨부"][0], result["배경"] - 1)
return result
def calculate_dynamic_ratio(
role_text_lengths: dict[str, int],
font_hierarchy: dict[str, float],
slide_width: int = 1280,
slide_height: int = 720,
preset: dict[str, Any] | None = None,
) -> tuple[int, int]:
"""sidebar 텍스트 양에서 body:sidebar 비율 역산.
고정 65:35가 아니라 텍스트 양 기반.
Returns:
(body_pct, sidebar_pct) 예: (70, 30) or (65, 35)
"""
# 프리셋에서 기본 비율 가져오기
preset_body_pct = 0
preset_sidebar_pct = 0
if preset:
zones = preset.get("zones", {})
for zone_name, zone_info in zones.items():
if zone_name == "body":
preset_body_pct = zone_info.get("width_pct", 0)
elif zone_name == "sidebar":
preset_sidebar_pct = zone_info.get("width_pct", 0)
sidebar_text = role_text_lengths.get("첨부", 0)
body_text = sum(v for k, v in role_text_lengths.items() if k != "첨부" and k != "결론")
total_text = body_text + sidebar_text
if total_text <= 0 or sidebar_text <= 0:
# sidebar 텍스트 없으면 프리셋의 기본 비율 사용
if preset_body_pct > 0 and preset_sidebar_pct > 0:
return (preset_body_pct, preset_sidebar_pct)
return (100, 0)
# 텍스트 비율에서 순수 계산
sidebar_ratio = sidebar_text / total_text
sidebar_pct = max(1, int(sidebar_ratio * 100))
body_pct = 100 - sidebar_pct
return (body_pct, sidebar_pct)
def calculate_design_budget(
container_height_px: int,
container_width_px: int,
block_schema: dict,
font_size: float,
padding: int | None = None,
) -> dict:
"""블록 schema 기반 디자인 요소 크기 역산.
텍스트 영역 확보 후 남은 공간 = 디자인 요소 예산.
텍스트를 줄이는 것이 아니라 도형/이미지/CSS 요소의 크기를 맞추는 방향.
Args:
container_height_px: 컨테이너 높이
container_width_px: 컨테이너 너비
block_schema: catalog.yaml의 해당 블록 schema
font_size: 이 역할의 확정된 폰트 크기 (T-5에서 결정)
padding: 컨테이너 내부 패딩
Returns:
{
"text_height_px": int, # 텍스트가 차지하는 높이
"available_height_px": int, # 디자인 요소 가용 높이
"available_width_px": int, # 디자인 요소 가용 너비
"max_circle_diameter": int, # 원형 요소 최대 지름
"max_img_width": int, # 이미지 최대 너비
"max_img_height": int, # 이미지 최대 높이
"fits": bool, # 디자인 요소가 들어가는지
}
"""
from src.fit_verifier import _load_design_tokens
tokens = _load_design_tokens()
if padding is None:
padding = tokens["spacing_block"]
# 블록 schema에서 텍스트 슬롯별 높이 합산
text_height = 0
for slot_name, spec in block_schema.items():
if slot_name.startswith("max_"):
continue
if not isinstance(spec, dict):
continue
slot_lines = spec.get("max_lines", 1)
slot_font = spec.get("font_size", font_size)
# line-height는 typography constant
text_height += int(slot_lines * (slot_font * 1.6))
remaining_height = container_height_px - text_height - padding * 2
remaining_width = container_width_px - padding * 2
border_w = tokens.get("border_width", tokens.get("accent_border", 1))
return {
"text_height_px": text_height,
"available_height_px": max(0, remaining_height),
"available_width_px": max(0, remaining_width),
"max_circle_diameter": max(0, min(remaining_height, remaining_width) - border_w * 2),
"max_img_width": max(0, remaining_width),
"max_img_height": max(0, remaining_height),
"fits": remaining_height >= 0,
}
def _estimate_capacity(width_px: int, font_size: float, height_px: int) -> int:
"""주어진 공간에서 수용 가능한 총 글자 수."""
from src.fit_verifier import _load_design_tokens
tokens = _load_design_tokens()
padding = tokens["spacing_block"]
char_width = font_size * CHAR_WIDTH_RATIO
inner_width = max(1, width_px - padding * 2)
chars_per_line = max(1, int(inner_width / char_width))
inner_height = max(1, height_px - padding * 2)
line_height_px = font_size * 1.4 # line-height (typography)
max_lines = max(1, int(inner_height / line_height_px))
return chars_per_line * max_lines
# ──────────────────────────────────────
@@ -101,28 +346,60 @@ def calculate_container_specs(
zone_roles[zone] = []
zone_roles[zone].append((role_name, role_info))
# tokens.css에서 spacing 읽기 (하드코딩 방지)
from src.fit_verifier import _load_design_tokens
tokens = _load_design_tokens()
slide_padding = tokens["spacing_page"] # --spacing-page
for zone_name, role_list in zone_roles.items():
zone_info = zones.get(zone_name, {})
zone_budget = zone_info.get("budget_px", 490)
zone_width_pct = zone_info.get("width_pct", 100)
zone_width_px = int(slide_width * zone_width_pct / 100 * 0.85) # 패딩 제외
# zone budget: weight 비율로 전체 가용 공간 배정
# weight는 초기 배정 비율. before→filled→after에서 조정됨.
header_height = tokens.get("header_height", 66)
total_available = slide_height - slide_padding * 2 - header_height - gap_px * 2
zone_weight_sum = sum(info.get("weight", 0) for _, info in role_list)
all_weight_sum = sum(
info.get("weight", 0)
for roles in zone_roles.values()
for _, info in roles
)
if all_weight_sum > 0 and zone_weight_sum > 0:
zone_budget = int(total_available * zone_weight_sum / all_weight_sum)
else:
# fallback: 프리셋 또는 동적 계산
zone_budget = zone_info.get("budget_px") or total_available
zone_width_pct = zone_info.get("width_pct", 0)
# 패딩 제외 폭: 슬라이드 폭 - 좌우 패딩
slide_inner_width = slide_width - slide_padding * 2
zone_width_px = int(slide_inner_width * zone_width_pct / 100) if zone_width_pct > 0 else slide_inner_width
# 이 zone 안의 역할별 비중 비율 계산
total_weight = sum(info.get("weight", 0.25) for _, info in role_list)
# Kei가 weight를 반드시 제공해야 함 (없으면 균등 배분)
total_weight = sum(info.get("weight", 0) for _, info in role_list)
if total_weight <= 0:
total_weight = 1.0
# weight가 없으면 균등 배분
total_weight = len(role_list)
for _, info in role_list:
info.setdefault("weight", 1)
# 간격 제외
total_gap = gap_px * max(0, len(role_list) - 1)
available = zone_budget - total_gap
# 최소 높이: catalog에서 가장 작은 블록의 min_height_px
from src.block_reference import _load_catalog
min_block_h = min(
(b.get("min_height_px", 0) for b in _load_catalog() if b.get("min_height_px", 0) > 0),
default=1,
)
for role_name, role_info in role_list:
weight = role_info.get("weight", 0.25)
weight = role_info.get("weight", 1)
topic_ids = role_info.get("topic_ids", [])
# 비중 비율로 높이 할당
ratio = weight / total_weight
height_px = max(50, int(available * ratio))
height_px = max(min_block_h, int(available * ratio))
# 블록 내부 제약 계산 — topic당 높이로 판단
topic_count = max(1, len(topic_ids))
@@ -157,27 +434,43 @@ def calculate_container_specs(
def _max_allowed_height_cost(container_height_px: int) -> str:
"""컨테이너 높이에서 허용되는 최대 height_cost."""
if container_height_px >= 350:
return "xlarge"
elif container_height_px >= 200:
return "large"
elif container_height_px >= 80:
return "medium"
else:
return "compact"
"""컨테이너 높이에서 허용되는 최대 height_cost.
catalog.yaml 블록들의 min_height_px 기반 동적 계산.
"""
ranges = _get_height_cost_px_range()
# 높은 cost부터 확인: 컨테이너가 해당 cost의 최소 높이 이상이면 허용
for cost in ["xlarge", "large", "medium", "compact"]:
if cost in ranges:
min_h, _ = ranges[cost]
if container_height_px >= min_h:
return cost
return "compact"
def _determine_typography(per_block_height_px: int) -> tuple[float, int, float]:
"""컨테이너 높이에 따른 폰트/패딩/줄간격 결정."""
if per_block_height_px >= 300:
return (15.2, 20, 1.7)
elif per_block_height_px >= 150:
return (14.0, 14, 1.6)
elif per_block_height_px >= 80:
return (13.0, 10, 1.5)
"""컨테이너 높이에 따른 폰트/패딩/줄간격 결정.
font-size와 line-height는 typography constant (허용).
padding은 tokens.css의 spacing 값에서 가져옴.
"""
from src.fit_verifier import _load_design_tokens
tokens = _load_design_tokens()
# height_cost 범위에서 어떤 급인지 판단
ranges = _get_height_cost_px_range()
xlarge_min = ranges.get("xlarge", (0, 0))[0]
large_min = ranges.get("large", (0, 0))[0]
medium_min = ranges.get("medium", (0, 0))[0]
if per_block_height_px >= xlarge_min and xlarge_min > 0:
return (15.2, tokens["spacing_block"], 1.7) # font 15.2, padding=--spacing-block, lh 1.7
elif per_block_height_px >= large_min and large_min > 0:
return (14.0, tokens["spacing_inner"], 1.6) # font 14, padding=--spacing-inner, lh 1.6
elif per_block_height_px >= medium_min and medium_min > 0:
return (13.0, tokens["spacing_small"], 1.5) # font 13, padding=--spacing-small, lh 1.5
else:
return (12.0, 8, 1.4)
return (12.0, tokens["spacing_small"], 1.4) # font 12, padding=--spacing-small, lh 1.4
def _calculate_block_constraints(
@@ -188,11 +481,17 @@ def _calculate_block_constraints(
line_height: float,
padding_px: int,
) -> dict:
"""컨테이너 크기에서 블록 내부 제약을 수학적으로 계산."""
per_topic_height = max(30, (height_px - padding_px * 2) // topic_count)
"""컨테이너 크기에서 블록 내부 제약을 수학적으로 계산.
모든 수치는 입력 파라미터(이전 Stage 결과) + font metric에서 도출.
"""
per_topic_height = max(1, (height_px - padding_px * 2) // max(1, topic_count))
line_height_px = font_size_px * line_height
max_lines = max(1, int(per_topic_height / line_height_px))
chars_per_line = max(5, int((width_px - padding_px * 2) / (font_size_px * 0.95)))
max_lines = max(1, int(per_topic_height / max(1, line_height_px)))
# chars_per_line: CHAR_WIDTH_RATIO(font metric)로 계산
char_width = font_size_px * CHAR_WIDTH_RATIO
usable_width = max(1, width_px - padding_px * 2)
chars_per_line = max(1, int(usable_width / max(1, char_width)))
max_items = max(1, max_lines // 2)
max_chars_total = max_lines * chars_per_line
@@ -200,8 +499,8 @@ def _calculate_block_constraints(
"max_lines": max_lines,
"max_items": max_items,
"chars_per_line": chars_per_line,
"max_chars_total": max(20, max_chars_total),
"max_chars_per_item": max(20, max_chars_total // max(1, max_items)),
"max_chars_total": max(1, max_chars_total),
"max_chars_per_item": max(1, max_chars_total // max(1, max_items)),
}
@@ -249,7 +548,9 @@ def finalize_block_specs(
if find_container_for_topic(b.get("topic_id"), container_specs) == spec
and b.get("topic_id") is not None]
sibling_count = max(1, len(siblings))
per_block_height = max(40, spec.height_px // sibling_count)
# 최소 높이: catalog에서 가장 작은 블록의 min_height_px
_min_h = min((b.get("min_height_px", 1) for b in _load_catalog() if b.get("min_height_px", 0) > 0), default=1)
per_block_height = max(_min_h, spec.height_px // sibling_count)
# 폰트/패딩 결정
font_size, padding, line_h = _determine_typography(per_block_height)
@@ -318,31 +619,19 @@ def calculate_trim_chars(
# Phase Q-3: 글자수 예산 계산
# ──────────────────────────────────────
# 블록 유형별 구조적 오버헤드 (제목, 패딩, 간격 등 — px 단위)
# Phase Q 2차 테스트 기반 실측 보정: 실제 CSS padding/margin 기반
_BLOCK_STRUCTURAL_OVERHEAD: dict[str, int] = {
"card-numbered": 40, # 패딩 12*2=24 + gap 10 + border 2 + 여유
"card-icon-desc": 50, # 아이콘 40 + 패딩 + gap
"card-step-vertical": 50, # 마커 30 + 패딩 + gap
"dark-bullet-list": 52, # 패딩 20*2=40 + 제목 줄 12
"comparison-2col": 60, # 헤더*2 + 구분선 + 패딩
"compare-3col-badge": 60, # 헤더 행 40 + 배지 + 패딩
"compare-2col-split": 60, # 헤더 행 40 + 패딩
"table-simple-striped": 50, # 헤더 행 35 + 패딩
"banner-gradient": 36, # 패딩 16*2=32 + 여유
"callout-solution": 50, # 아이콘 + 제목 30 + 패딩 20
"callout-warning": 50, # 아이콘 + 제목 30 + 패딩 20
"quote-big-mark": 50, # 따옴표 장식 + 패딩 20*2
"quote-question": 76, # 패딩 28*2=56 + desc margin 10 + 여유 10 (실측 기반)
"compare-pill-pair": 52, # 외곽 패딩 6*2 + 내부 패딩 18*2 + 여유
"venn-diagram": 60, # SVG 구조 + 패딩
"process-horizontal": 50, # 화살표 + 번호 36 + 패딩
"flow-arrow-horizontal": 30, # 캡슐 + 화살표 + 패딩
"keyword-circle-row": 60, # 원형 + 라벨 + 패딩
}
# 블록 유형별 구조적 오버헤드 — catalog.yaml의 padding_overhead_px에서 읽음
def _get_block_overhead(block_type: str) -> int:
"""catalog.yaml에서 블록의 padding_overhead_px를 읽어옴."""
from src.block_reference import _load_catalog
for b in _load_catalog():
if b["id"] == block_type:
return b.get("padding_overhead_px", 0)
return 0
# 같은 컨테이너 내 블록 간 gap (px)
_CONTAINER_BLOCK_GAP = 8
# 같은 컨테이너 내 블록 간 gap — tokens.css에서 읽음
def _get_block_gap() -> int:
from src.fit_verifier import _load_design_tokens
return _load_design_tokens()["spacing_small"]
def calculate_char_budget(
@@ -371,15 +660,16 @@ def calculate_char_budget(
topic_count = max(1, len(container_spec.topic_ids))
# 같은 컨테이너 내 블록 간 gap 차감
total_gap = _CONTAINER_BLOCK_GAP * max(0, topic_count - 1)
available_container_height = max(40, container_spec.height_px - total_gap)
total_gap = _get_block_gap() * max(0, topic_count - 1)
_min_h2 = min((b.get("min_height_px", 1) for b in _load_catalog() if b.get("min_height_px", 0) > 0), default=1)
available_container_height = max(_min_h2, container_spec.height_px - total_gap)
per_topic_px = available_container_height // topic_count
# 폰트 크기 결정
font_size, padding, line_h = _determine_typography(per_topic_px)
# 구조적 오버헤드
structural = _BLOCK_STRUCTURAL_OVERHEAD.get(block_type, 20)
structural = _get_block_overhead(block_type)
content_height = max(10, per_topic_px - structural)
# 줄 수 계산
@@ -387,7 +677,10 @@ def calculate_char_budget(
available_lines = max(1, int(content_height / line_height_px))
# 한국어 줄당 글자수 (폰트 크기 기반)
usable_width = container_spec.width_px * 0.85 # 패딩 제외
# 패딩 제외: tokens.css의 spacing_page × 2가 아니라 블록 내부 padding
# 블록 padding은 container_spec.block_constraints에 있을 수 있음
block_padding = container_spec.block_constraints.get("padding_px", 0)
usable_width = container_spec.width_px - block_padding * 2 if block_padding else container_spec.width_px
chars_per_line = max(5, int(usable_width / font_size))
# 항목 수 제한 (블록 정의 참조)

780
src/step_visualizer.py Normal file
View File

@@ -0,0 +1,780 @@
"""파이프라인 각 Stage 실행 후 자동으로 HTML 시각화를 생성.
save_snapshot()에서 호출됨. 각 stage의 실제 context 데이터로 HTML 생성.
JSON context 파일과 동일한 stage 이름을 사용.
생성되는 파일 (JSON context 파일과 1:1 매칭):
stage_0.html — MDX 정규화 결과 (섹션, 팝업, 이미지)
stage_1a.html — Kei 꼭지 + 영역 배정 (테이블)
stage_1b.html — 컨셉 구체화 (source_data, summary 추가)
stage_1_5a.html — 빈 컨테이너 (1280x720)
stage_1_5a_content.html — 컨테이너에 실제 콘텐츠 배치
stage_1_5b.html — 디자인 예산 (영역별 available_height/width)
stage_1_7.html — 블록 선택 표시
stage_1_8_fit_before.html — 적합성 검증 (재배분 전)
stage_1_8_fit_after.html — 재배분 후 + 보강 결과
stage_1_8_blocks.html — 재배분 후 컨테이너에 블록 배치
stage_2.html — HTML 생성 결과 (영역별 생성된 HTML)
stage_3.html — 렌더링 조립 → final.html 링크
stage_4.html — 품질 게이트 (측정값, 점수)
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Any, TYPE_CHECKING
if TYPE_CHECKING:
from src.pipeline_context import PipelineContext
logger = logging.getLogger(__name__)
COLORS = {"배경": "#dc2626", "본심": "#2563eb", "첨부": "#16a34a", "결론": "#7c3aed"}
FONT_MAP = {"배경": "bg", "본심": "core", "첨부": "sidebar", "결론": "key_msg"}
def generate_step_html(stage_name: str, ctx: "PipelineContext", steps_dir: Path) -> None:
"""stage_name에 따라 적절한 시각화 HTML 생성. 파일명은 JSON context와 1:1 매칭."""
try:
if stage_name == "stage_0":
_gen_stage_0(ctx, steps_dir)
elif stage_name == "stage_1a":
_gen_stage_1a(ctx, steps_dir)
elif stage_name == "stage_1b":
_gen_stage_1b(ctx, steps_dir)
elif stage_name == "stage_1_5a":
_gen_stage_1_5a(ctx, steps_dir)
_gen_stage_1_5a_content(ctx, steps_dir)
elif stage_name == "stage_1_5b":
_gen_stage_1_5b(ctx, steps_dir)
elif stage_name == "stage_1_7":
_gen_stage_1_7(ctx, steps_dir)
elif stage_name == "stage_1_8":
# before와 filled는 pipeline.py Stage 1.8 내부에서 before context로 이미 생성됨
# step_visualizer는 after context로 호출되므로 덮어쓰면 안 됨
# blocks와 fit_after만 생성 (after 상태 반영)
_gen_stage_1_8_blocks(ctx, steps_dir)
_gen_stage_1_8_fit_after(ctx, steps_dir)
elif stage_name == "stage_2":
_gen_stage_2(ctx, steps_dir)
elif stage_name == "stage_3":
_gen_stage_3(ctx, steps_dir)
elif stage_name == "stage_4":
_gen_stage_4(ctx, steps_dir)
except Exception as e:
logger.warning(f"[step_viz] {stage_name} 시각화 실패: {e}")
# ══════════════════════════════════════
# 공통
# ══════════════════════════════════════
def _tokens():
from src.fit_verifier import _load_design_tokens
return _load_design_tokens()
def _calc_coords(containers: dict, ratio: tuple) -> dict:
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
bg_h = gh(containers.get("배경", {}))
core_h = gh(containers.get("본심", {}))
sb_h = gh(containers.get("첨부", {}))
ft_h = gh(containers.get("결론", {}))
bg_top = pad + header_h + gap
core_top = bg_top + bg_h + small
ft_top = max(core_top + core_h, bg_top + sb_h) + gap
return {
"header": {"l": pad, "t": pad, "w": inner_w, "h": header_h},
"배경": {"l": pad, "t": bg_top, "w": body_w, "h": bg_h},
"본심": {"l": pad, "t": core_top, "w": body_w, "h": core_h},
"첨부": {"l": pad + body_w + gap, "t": bg_top, "w": sidebar_w, "h": sb_h},
"결론": {"l": pad, "t": ft_top, "w": inner_w, "h": ft_h},
}
def _wrap(title, subtitle, slide_body):
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>
<div style="width:1280px;height:720px;background:white;position:relative;border:1px solid #ccc;">
{slide_body}
</div></body></html>"""
def _hdr(c, title):
return (f'<div style="position:absolute;left:{c["l"]}px;top:{c["t"]}px;width:{c["w"]}px;height:{c["h"]}px;'
f'background:#f8fafc;border-bottom:3px solid #2563eb;display:flex;align-items:center;'
f'padding:0 20px;font-size:22px;font-weight:900;color:#1e293b;">{title}</div>\n')
def _box(c, role, inner, extra=""):
cl = COLORS.get(role, "#333")
return (f'<div style="position:absolute;left:{c["l"]}px;top:{c["t"]}px;width:{c["w"]}px;height:{c["h"]}px;'
f'border:2px solid {cl};border-radius:6px;background:{cl}08;overflow:hidden;{extra}">{inner}</div>\n')
# ══════════════════════════════════════
# Stage 0: MDX 정규화
# ══════════════════════════════════════
def _gen_stage_0(ctx, steps_dir):
"""MDX 정규화 결과: 섹션, 팝업, 이미지, 테이블 목록."""
norm = ctx.normalized if hasattr(ctx, 'normalized') else {}
if hasattr(norm, 'model_dump'):
norm = norm.model_dump()
elif not isinstance(norm, dict):
norm = {}
sections = norm.get("sections", [])
popups = norm.get("popups", [])
images = norm.get("images", [])
tables = norm.get("tables", [])
title = norm.get("title", ctx.analysis.title if ctx.analysis else "")
sec_rows = ""
for i, s in enumerate(sections):
heading = s.get("heading", "") if isinstance(s, dict) else ""
content = s.get("content", "") if isinstance(s, dict) else str(s)
preview = content[:120].replace("<", "&lt;") + ("..." if len(content) > 120 else "")
bg = "#f8fafc" if i % 2 == 0 else "#fff"
sec_rows += f'<tr style="background:{bg};"><td style="padding:6px 8px;">{i+1}</td><td style="padding:6px 8px;font-weight:700;">{heading}</td><td style="padding:6px 8px;font-size:11px;">{preview}</td></tr>\n'
popup_rows = ""
for p in popups:
pt = p.get("title", "") if isinstance(p, dict) else str(p)
pc = p.get("content", "") if isinstance(p, dict) else ""
popup_rows += f'<tr><td style="padding:6px 8px;font-weight:700;">{pt}</td><td style="padding:6px 8px;font-size:11px;">{len(pc)}자</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>
</head><body>
<div style="font-size:16px;font-weight:bold;margin-bottom:8px;">Stage 0: MDX 정규화</div>
<div style="font-size:12px;color:#555;margin-bottom:12px;">제목: <b>{title}</b> | 섹션: {len(sections)}개 | 팝업: {len(popups)}개 | 이미지: {len(images)}개 | 테이블: {len(tables)}개</div>
<div style="font-size:13px;font-weight:700;margin-bottom:4px;">섹션</div>
<table style="border-collapse:collapse;font-size:12px;width:100%;max-width:900px;margin-bottom:16px;">
<tr style="background:#1e293b;color:white;"><th style="padding:8px;">#</th><th style="padding:8px;">heading</th><th style="padding:8px;">content (미리보기)</th></tr>{sec_rows}</table>
<div style="font-size:13px;font-weight:700;margin-bottom:4px;">팝업</div>
<table style="border-collapse:collapse;font-size:12px;width:100%;max-width:600px;">
<tr style="background:#1e293b;color:white;"><th style="padding:8px;">title</th><th style="padding:8px;">분량</th></tr>{popup_rows}</table>
</body></html>"""
(steps_dir / "stage_0.html").write_text(html, encoding="utf-8")
# ══════════════════════════════════════
# Stage 1A: Kei 꼭지
# ══════════════════════════════════════
def _gen_stage_1a(ctx, steps_dir):
ps = ctx.page_structure.roles
rm = {}
for role, info in ps.items():
if isinstance(info, dict):
for tid in info.get("topic_ids", []):
rm[tid] = role
rows = ""
for t in ctx.topics:
role = rm.get(t.id, "?")
c = COLORS.get(role, "#333")
bg = "#f8fafc" if t.id % 2 == 0 else "#fff"
rows += (f'<tr style="background:{bg};"><td style="padding:6px 8px;text-align:center;">{t.id}</td>'
f'<td style="padding:6px 8px;font-weight:700;">{t.title}</td>'
f'<td style="padding:6px 8px;">{t.purpose}</td><td style="padding:6px 8px;">{t.layer}</td>'
f'<td style="padding:6px 8px;">{t.relation_type}</td>'
f'<td style="padding:6px 8px;color:{c};font-weight:700;">{role}</td></tr>\n')
ps_info = "<br>".join(f"{r}: topic_ids={info.get('topic_ids')}, weight={info.get('weight')}"
for r, info in ps.items() if isinstance(info, dict))
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>
</head><body>
<div style="font-size:16px;font-weight:bold;margin-bottom:8px;">Stage 1A/1B: Kei 꼭지 + 영역 배정</div>
<table style="border-collapse:collapse;font-size:12px;width:100%;max-width:900px;">
<tr style="background:#1e293b;color:white;"><th style="padding:8px;">ID</th><th style="padding:8px;">제목</th>
<th style="padding:8px;">purpose</th><th style="padding:8px;">layer</th><th style="padding:8px;">relation_type</th>
<th style="padding:8px;">영역</th></tr>{rows}</table>
<div style="margin-top:12px;font-size:12px;color:#555;"><b>페이지 구조:</b><br>{ps_info}</div></body></html>"""
(steps_dir / "stage_1a.html").write_text(html, encoding="utf-8")
# ══════════════════════════════════════
# Stage 1B: 컨셉 구체화
# ══════════════════════════════════════
def _gen_stage_1b(ctx, steps_dir):
"""Stage 1B 후 꼭지에 source_data, summary가 추가된 상태."""
ps = ctx.page_structure.roles
rm = {}
for role, info in ps.items():
if isinstance(info, dict):
for tid in info.get("topic_ids", []):
rm[tid] = role
rows = ""
for t in ctx.topics:
role = rm.get(t.id, "?")
c = COLORS.get(role, "#333")
bg = "#f8fafc" if t.id % 2 == 0 else "#fff"
sd = (t.source_data or "")[:150]
sd_display = sd.replace("<", "&lt;") + ("..." if len(t.source_data or "") > 150 else "")
summary = (t.summary or "")[:100] if hasattr(t, 'summary') else ""
rows += (f'<tr style="background:{bg};"><td style="padding:6px 8px;text-align:center;">{t.id}</td>'
f'<td style="padding:6px 8px;font-weight:700;">{t.title}</td>'
f'<td style="padding:6px 8px;color:{c};">{role}</td>'
f'<td style="padding:6px 8px;">{t.layer}</td>'
f'<td style="padding:6px 8px;font-size:10px;">{sd_display}</td>'
f'<td style="padding:6px 8px;font-size:10px;color:#555;">{summary}</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>
</head><body>
<div style="font-size:16px;font-weight:bold;margin-bottom:8px;">Stage 1B: 컨셉 구체화</div>
<div style="font-size:11px;color:#666;margin-bottom:8px;">Stage 1A의 꼭지에 source_data(원본 텍스트)와 summary가 추가됨</div>
<table style="border-collapse:collapse;font-size:12px;width:100%;">
<tr style="background:#1e293b;color:white;"><th style="padding:8px;">ID</th><th style="padding:8px;">제목</th>
<th style="padding:8px;">영역</th><th style="padding:8px;">layer</th>
<th style="padding:8px;">source_data (미리보기)</th><th style="padding:8px;">summary</th></tr>{rows}</table></body></html>"""
(steps_dir / "stage_1b.html").write_text(html, encoding="utf-8")
# ══════════════════════════════════════
# Stage 1.5a: 빈 컨테이너
# ══════════════════════════════════════
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)
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)
r = ctx.container_ratio
html = _wrap(f"Step 1: 빈 컨테이너 (Stage 1.5a)", f"비율 {r[0]}:{r[1]}", body)
(steps_dir / "stage_1_5a.html").write_text(html, encoding="utf-8")
# ══════════════════════════════════════
# Stage 1.5a: 컨테이너에 콘텐츠 배치
# ══════════════════════════════════════
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)
ps = ctx.page_structure.roles
topic_map = {t.id: t for t in ctx.topics}
for role in ["배경", "본심", "첨부", "결론"]:
c = coords[role]
cl = COLORS[role]
info = ps.get(role, {})
tids = info.get("topic_ids", []) if isinstance(info, dict) else []
lines = [f'<div style="font-size:10px;color:{cl};font-weight:700;margin-bottom:4px;">{role}</div>']
for tid in tids:
t = topic_map.get(tid)
if not t:
continue
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(", "):
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>')
inner = f'<div style="padding:6px 10px;overflow:hidden;">{"".join(lines)}</div>'
body += _box(c, role, inner)
html = _wrap("Step 1b: 콘텐츠 배치 (꼭지 → 컨테이너)", "각 컨테이너에 배정된 꼭지의 source_data", body)
(steps_dir / "stage_1_5a_content.html").write_text(html, encoding="utf-8")
# ══════════════════════════════════════
# Stage 1.5b: 디자인 예산
# ══════════════════════════════════════
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)
for role in ["배경", "본심", "첨부", "결론"]:
c = coords[role]
cl = COLORS[role]
ci = ctx.containers.get(role)
if not ci:
continue
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)
html = _wrap("Stage 1.5b: 디자인 예산", "영역별 available_height/width + fits 여부", body)
(steps_dir / "stage_1_5b.html").write_text(html, encoding="utf-8")
# ══════════════════════════════════════
# Stage 1.7: 블록 선택
# ══════════════════════════════════════
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)
for role in ["배경", "본심", "첨부", "결론"]:
c = coords[role]
cl = COLORS[role]
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>']
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>')
inner = f'<div style="padding:6px 10px;">{"".join(lines)}</div>'
body += _box(c, role, inner)
html = _wrap("Step 2: 블록 선택 (Stage 1.7)", "layer 기반 주종 판단. 컨테이너 위에 블록 표시.", body)
(steps_dir / "stage_1_7.html").write_text(html, encoding="utf-8")
# ══════════════════════════════════════
# Stage 1.8: 텍스트/그림 채운 상태 (filled)
# ══════════════════════════════════════
def _gen_stage_1_8_filled(ctx, steps_dir):
"""블록 디자인에 텍스트를 채운 상태. 공통 조립 함수(block_assembler) 사용."""
from src.block_assembler import assemble_slide_html
slide_html = assemble_slide_html(ctx)
# 시각화 제목 삽입
header = (
'<div style="font-size:16px;font-weight:bold;margin-bottom:4px;">'
'Stage 1.8: 블록 디자인에 텍스트 채운 상태 (fit 검증 전)</div>\n'
'<div style="font-size:11px;color:#666;margin-bottom:8px;">'
'블록 CSS + structured_text + font_hierarchy. 공통 조립 함수 사용.</div>\n'
)
html = slide_html.replace('</head><body>', '</head><body>\n' + header, 1)
(steps_dir / "stage_1_8_filled.html").write_text(html, encoding="utf-8")
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]
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
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)
(steps_dir / "stage_1_8_fit_before.html").write_text(html, encoding="utf-8")
# ══════════════════════════════════════
# Stage 1.8: 재배분 후 + 보강
# ══════════════════════════════════════
def _gen_stage_1_8_fit_after(ctx, steps_dir):
fit = ctx.fit_result
enh = ctx.enhancement_result
redist = fit.get("redistribution", {})
roles_fit = fit.get("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 "슬라이드"
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)
new_h = int(redist.get(role, old_h))
needed = rf.get("total_required_px", 0)
delta = new_h - old_h
ref_list = ctx.references.get(role, [])
blocks = ", ".join(r.block_id for r in ref_list)
delta_str = f" <span style='color:#16a34a;'>({delta:+d}px)</span>" if delta != 0 else ""
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)
(steps_dir / "stage_1_8_fit_after.html").write_text(html, encoding="utf-8")
# ══════════════════════════════════════
# Stage 1.8: 블록 디자인을 컨테이너에 배치
# ══════════════════════════════════════
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>"""
(steps_dir / "stage_1_8_blocks.html").write_text(html, encoding="utf-8")
def _gen_stage_2(ctx, steps_dir):
"""Stage 2 결과: 영역별 Sonnet 출력을 실제 렌더링하여 보여줌.
각 역할(배경/본심/첨부/결론)의 HTML을 개별 컨테이너에 실제 렌더링."""
gen = ctx.generated_html or {}
sub_layouts = ctx.sub_layouts or {}
ps = ctx.page_structure.roles
# body_html에서 배경/본심 분리 (spacer로 구분)
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
if core_html and "본심" in ps:
role_htmls["본심"] = core_html
if sidebar_html and "첨부" in ps:
role_htmls["첨부"] = sidebar_html
if footer_html and "결론" in ps:
role_htmls["결론"] = footer_html
# 각 역할을 컨테이너 크기에 맞게 실제 렌더링
fit = ctx.fit_result or {}
redist = fit.get("redistribution", {})
sections = []
for role in ["배경", "본심", "첨부", "결론"]:
rhtml = role_htmls.get(role, "")
if not rhtml:
continue
cl = COLORS.get(role, "#333")
ci = ctx.containers.get(role)
if not ci:
continue
h = int(redist.get(role, ci.height_px))
w = ci.width_px
# sub_layout 정보
layout = sub_layouts.get(role, {})
scs = layout.get("sub_containers", [])
sc_desc = " + ".join(f'{sc["name"]}({int(sc["width_px"])}×{int(sc["height_px"])}px)' for sc in scs) if scs else ""
sections.append(
f'<div style="margin-bottom:20px;">'
f'<div style="font-size:13px;font-weight:700;color:{cl};margin-bottom:4px;">'
f'{role} ({w}×{h}px)'
f'{"" + sc_desc if sc_desc else ""}</div>'
f'<div style="width:{w}px;height:{h}px;border:2px solid {cl};border-radius:6px;'
f'overflow:hidden;background:white;font-family:Pretendard Variable,sans-serif;'
f'word-break:keep-all;">{rhtml}</div></div>'
)
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;}}</style>
</head><body>
<div style="font-size:16px;font-weight:bold;margin-bottom:4px;">Stage 2: 영역별 HTML 생성 결과 (Sonnet)</div>
<div style="font-size:11px;color:#666;margin-bottom:12px;">각 역할의 Sonnet 출력을 컨테이너 크기에 맞게 실제 렌더링</div>
{"".join(sections)}
</body></html>"""
(steps_dir / "stage_2.html").write_text(html, encoding="utf-8")
def _gen_stage_3(ctx, steps_dir):
"""Stage 3 결과: rendered_html을 별도 파일로 저장 + 링크.
rendered_html 자체가 완성 HTML이므로 직접 렌더링 가능."""
rendered = ctx.rendered_html or ""
if rendered:
# rendered_html을 별도 파일로 저장하여 브라우저에서 직접 확인 가능
(steps_dir / "stage_3_rendered.html").write_text(rendered, encoding="utf-8")
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;}}</style>
</head><body>
<div style="font-size:16px;font-weight:bold;margin-bottom:4px;">Stage 3: 렌더링 조립 결과</div>
<div style="font-size:11px;color:#666;margin-bottom:8px;">Stage 2의 영역별 HTML을 슬라이드 프레임(CSS Grid)에 배치 + 후처리 적용</div>
<p style="margin-bottom:8px;"><a href="stage_3_rendered.html" style="font-size:16px;font-weight:700;">렌더링 결과 보기 (1280×720) →</a></p>
<p><a href="../final.html" style="font-size:14px;">final.html 보기 →</a></p>
<div style="margin-top:16px;font-size:12px;color:#555;">
Stage 3 후처리: sidebar width:100% 조정, 폰트 캡핑 (배경≤{ctx.font_hierarchy.bg}px, 첨부≤{ctx.font_hierarchy.sidebar}px), overflow 제거, bold 변환
</div>
</body></html>"""
(steps_dir / "stage_3.html").write_text(html, encoding="utf-8")
# ══════════════════════════════════════
# Stage 4: 품질 게이트
# ══════════════════════════════════════
def _gen_stage_4(ctx, steps_dir):
"""Stage 4 결과: 측정값 + 품질 점수."""
measurement = ctx.measurement or {}
quality_score = ctx.quality_score if hasattr(ctx, 'quality_score') else "N/A"
slide_m = measurement.get("slide", {})
zones = measurement.get("zones", {})
zone_rows = ""
for zone_name, zone_data in zones.items():
overflowed = zone_data.get("overflowed", False)
excess = zone_data.get("excess_px", 0)
client_h = zone_data.get("clientHeight", 0)
scroll_h = zone_data.get("scrollHeight", 0)
icon = "" if overflowed else ""
bg = "#fee2e2" if overflowed else "#f0fdf4"
zone_rows += (f'<tr style="background:{bg};"><td style="padding:6px 8px;">{icon} {zone_name}</td>'
f'<td style="padding:6px 8px;">{client_h}px</td>'
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"
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>
</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>
</body></html>"""
(steps_dir / "stage_4.html").write_text(html, encoding="utf-8")

380
src/validators.py Normal file
View File

@@ -0,0 +1,380 @@
"""Phase T-2: Stage 1A/1B 검증 시스템.
Stage 2 이후 검증(content_verifier.py)과 분리된 독립 모듈.
AI(Kei)의 콘텐츠 분석 결과를 원본과 대조하여 검증.
검증 4계층:
1. 형식 검증 (Pydantic) — 값 범위, 유효 enum, null 체크
2. 내용 검증 (코드+대조) — 결과가 원본에 대해 적절한가
3. 모순 탐지 (결정 테이블) — purpose × relation_type 논리 모순
4. 피드백 생성 — Self-Refine 패턴: localization + evidence + instruction
도구:
- kiwipiepy: 한국어 명사/키워드 추출 (T-2 조사: Windows 즉시 동작, Java 불필요)
- regex: 관계 표현 패턴 (T-2 조사: 7개 relation_type별 15개+ 패턴)
"""
from __future__ import annotations
import re
import logging
from typing import Any
logger = logging.getLogger(__name__)
# kiwipiepy lazy loading (첫 import 시 ~50MB 모델 다운로드)
_kiwi = None
def _get_kiwi():
global _kiwi
if _kiwi is None:
from kiwipiepy import Kiwi
_kiwi = Kiwi()
return _kiwi
# ══════════════════════════════════════
# 한국어 키워드 추출 (kiwipiepy)
# ══════════════════════════════════════
def extract_keywords_kiwi(text: str) -> set[str]:
"""kiwipiepy로 명사 + 영문 약어 추출.
기존 content_verifier.py의 regex extract_keywords()보다 정확:
- "정립되지""정립" 추출 가능 (어미 분리)
- "혼용되어""혼용" 추출 가능
- 복합 조사 "에서는" 등 정확 분리
"""
kiwi = _get_kiwi()
tokens = kiwi.tokenize(text)
keywords = set()
for t in tokens:
# NNG: 일반명사, NNP: 고유명사, SL: 외국어(영문약어)
if t.tag in ("NNG", "NNP", "SL") and len(t.form) >= 2:
keywords.add(t.form)
return keywords
# ══════════════════════════════════════
# 관계 표현 패턴 (T-2 조사 결과)
# ══════════════════════════════════════
RELATION_PATTERNS: dict[str, list[str]] = {
"comparison": [
r"[Vv][Ss]\.?", r"\s*비해", r"반면", r"차이점?", r"비교",
r"대비", r"\s*달리", r"\s*달리", r"한편", r"\s*반하여",
r"그에\s*비해", r"상이", r"구분", r"차별화",
],
"sequence": [
r"", r"이후", r"다음", r"먼저", r"\s*후", r"단계",
r"순서", r"[0-9]+차", r"최종적", r"\s*뒤", r"우선",
r"이어서", r"점진적", r"과정", r"\s*거쳐",
],
"hierarchy": [
r"상위", r"하위", r"속하", r"\s*일부", r"범주",
r"구성요소", r"체계", r"분류", r"계층", r"\s*나뉜?다",
r"종속", r"수직적", r"상하\s*관계", r"아우르", r"광의|협의",
],
"inclusion": [
r"포함", r"융합", r"통합", r"안에", r"속에", r"결합",
r"합쳐", r"아우르", r"망라", r"수렴", r"내포", r"포괄",
r"연계", r"접목", r"겹치|중복",
],
"cause_effect": [
r"때문에", r"따라서", r"결과", r"원인", r"\s*인해",
r"하여", r"해서", r"초래", r"야기", r"기인",
r"영향", r"유발", r"\s*결과", r"므로",
r"그래서", r"그러므로", r"\s*의해",
],
"definition": [
r"이란", r"정의", r"의미", r"개념",
r"\s*말한다", r"\s*뜻한다", r"\s*정의", r"가리킨다",
r"\s*함은", r"라고\s*한다", r"줄임말|약어|약자",
r"\s*해당", r"일컫", r"용어",
],
}
def detect_relation_evidence(text: str) -> dict[str, int]:
"""원본 텍스트에서 각 relation_type의 증거 수를 카운트."""
evidence = {}
for rel_type, patterns in RELATION_PATTERNS.items():
count = sum(1 for p in patterns if re.search(p, text))
evidence[rel_type] = count
return evidence
# ══════════════════════════════════════
# 모순 결정 테이블 (데이터로 정의)
# ══════════════════════════════════════
# purpose × relation_type 하드 모순 (이 조합은 논리적으로 불가능)
CONTRADICTIONS: dict[str, list[str]] = {
"결론강조": ["comparison", "sequence"], # 결론은 비교나 순서가 아님
"문제제기": ["sequence", "definition"], # 문제제기는 순서 나열이나 정의가 아님
"용어정의": ["hierarchy", "cause_effect"], # 정의 나열은 상하위나 인과가 아님
"구조시각화": ["none"], # 시각화할 관계가 없으면 구조시각화가 아님
}
# 소프트 경고 (의심 수준)
SOFT_WARNINGS: dict[str, list[str]] = {
"핵심전달": ["definition"], # 핵심전달에 definition은 약간 의심
}
# ══════════════════════════════════════
# Stage 1A 검증
# ══════════════════════════════════════
VALID_PURPOSES = {"문제제기", "근거사례", "핵심전달", "용어정의", "결론강조", "구조시각화"}
VALID_ROLES = {"flow", "reference"}
VALID_LAYERS = {"intro", "core", "supporting", "conclusion"}
def validate_stage_1a(
analysis: dict[str, Any],
clean_text: str,
) -> list[dict]:
"""Stage 1A(Kei 꼭지 추출) 결과 검증.
Args:
analysis: Kei API 반환 dict
clean_text: Stage 0에서 정규화된 텍스트
Returns:
에러 리스트 (빈 리스트 = 통과)
"""
errors = []
topics = analysis.get("topics", [])
page_struct = analysis.get("page_structure", {})
# ── 형식 검증 ──
if not topics:
errors.append({
"severity": "FATAL",
"field": "topics",
"localization": "topics가 비어있음",
"instruction": "콘텐츠에서 최소 1개 꼭지를 추출하라",
})
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}",
})
# 본심 존재 + 본심 weight ≥ 0.3
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 이상 필요.",
})
# 필수 필드 검증
for t in topics:
tid = t.get("id", "?")
if not t.get("title"):
errors.append({
"severity": "RETRYABLE",
"field": f"topics[{tid}].title",
"localization": f"topic {tid}에 title 없음",
"instruction": "각 topic에 title을 부여하라",
})
if t.get("purpose") and t["purpose"] not in VALID_PURPOSES:
errors.append({
"severity": "RETRYABLE",
"field": f"topics[{tid}].purpose",
"localization": f"topic {tid} purpose '{t['purpose']}' 유효하지 않음",
"current_value": t["purpose"],
"instruction": f"유효한 purpose: {VALID_PURPOSES}",
})
# page_structure의 topic_ids가 실제 topics에 존재하는지
all_topic_ids = {t.get("id") for t in topics}
for role, info in page_struct.items():
if not isinstance(info, dict):
continue
for tid in info.get("topic_ids", []):
if tid not in all_topic_ids:
errors.append({
"severity": "RETRYABLE",
"field": f"page_structure.{role}.topic_ids",
"localization": f"{role}에 존재하지 않는 topic_id {tid}",
"instruction": f"topic_ids는 topics[].id에 존재하는 값만 사용하라. 현재 topics: {sorted(all_topic_ids)}",
})
# ── 내용 검증 (원본 대조) ──
if clean_text:
# 원본 ## 섹션 수 vs topic 수 비교
original_sections = re.findall(r"^## .+$", clean_text, re.MULTILINE)
if len(original_sections) > 0 and abs(len(topics) - len(original_sections)) > 2:
errors.append({
"severity": "RETRYABLE",
"field": "topics",
"localization": f"원본 ## 섹션 {len(original_sections)}개, topic {len(topics)}개 (차이 {abs(len(topics) - len(original_sections))})",
"evidence": f"원본 섹션: {[s[3:].strip()[:30] for s in original_sections]}",
"instruction": "원본의 주요 섹션이 topic에 매핑되었는지 확인하라",
})
# topic summary 키워드가 원본에 존재하는지 (kiwipiepy)
try:
orig_keywords = extract_keywords_kiwi(clean_text)
for t in topics:
summary = t.get("summary", "")
if not summary:
continue
summary_kw = extract_keywords_kiwi(summary)
if not summary_kw:
continue
overlap = summary_kw & orig_keywords
rate = len(overlap) / len(summary_kw) if summary_kw else 1.0
if rate < 0.5:
missing = summary_kw - orig_keywords
errors.append({
"severity": "RETRYABLE",
"field": f"topics[{t.get('id', '?')}].summary",
"localization": f"summary 키워드 보존율 {rate:.0%}",
"evidence": f"원본에 없는 키워드: {missing}",
"instruction": f"summary에 원본에 없는 표현을 추가하지 마라. 원본 키워드로 수정하라.",
})
except Exception as e:
logger.warning(f"[T-2] kiwipiepy 키워드 검증 실패: {e}")
return errors
# ══════════════════════════════════════
# Stage 1B 검증
# ══════════════════════════════════════
VALID_RELATION_TYPES = {"hierarchy", "cause_effect", "comparison", "sequence", "definition", "inclusion", "none"}
def validate_stage_1b(
topics: list[dict[str, Any]],
clean_text: str,
raw_content: str = "",
) -> list[dict]:
"""Stage 1B(컨셉 구체화) 결과 검증.
Args:
topics: Stage 1B 후 업데이트된 topics 리스트
clean_text: Stage 0에서 정규화된 텍스트
raw_content: 원본 MDX 전체 (popups/details 포함). 대조 범위 확장용.
Returns:
에러 리스트 (빈 리스트 = 통과)
"""
# 대조 범위: clean_text + raw_content (popups/details 내용 포함)
full_text = clean_text
if raw_content:
full_text = clean_text + "\n" + raw_content
errors = []
for t in topics:
tid = t.get("id", "?")
purpose = t.get("purpose", "")
relation_type = t.get("relation_type", "")
expression_hint = t.get("expression_hint", "")
source_data = t.get("source_data", "")
# ── 형식 검증 ──
if relation_type not in VALID_RELATION_TYPES:
errors.append({
"severity": "RETRYABLE",
"field": f"topics[{tid}].relation_type",
"localization": f"topic {tid}: 유효하지 않은 relation_type '{relation_type}'",
"current_value": relation_type,
"instruction": f"유효한 relation_type: {sorted(VALID_RELATION_TYPES)}",
})
if not expression_hint:
errors.append({
"severity": "RETRYABLE",
"field": f"topics[{tid}].expression_hint",
"localization": f"topic {tid}: expression_hint 비어있음",
"instruction": "expression_hint를 작성하라. 형식: 관계 선언 + 콘텐츠 설명 + 시각 지침",
})
# ── 모순 탐지 (결정 테이블) ──
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 purpose in SOFT_WARNINGS:
if relation_type in SOFT_WARNINGS[purpose]:
logger.warning(
f"[T-2 경고] topic {tid}: purpose '{purpose}' × relation_type '{relation_type}' 의심"
)
# ── 원본 대조: source_data 할루시네이션 감지 ──
# full_text 사용 (popups/details 내용 포함)
if source_data and full_text:
try:
source_kw = extract_keywords_kiwi(source_data)
orig_kw = extract_keywords_kiwi(full_text)
if source_kw:
overlap = source_kw & orig_kw
rate = len(overlap) / len(source_kw)
if rate < 0.4:
missing = source_kw - orig_kw
errors.append({
"severity": "RETRYABLE",
"field": f"topics[{tid}].source_data",
"localization": f"topic {tid}: source_data 키워드 보존율 {rate:.0%}",
"evidence": f"원본에 없는 키워드: {missing}",
"instruction": "source_data는 원본에 실제 존재하는 텍스트만 사용하라. 없는 출처를 만들어내지 마라.",
})
except Exception as e:
logger.warning(f"[T-2] source_data 검증 실패: {e}")
# ── 원본 대조: relation_type과 원본 언어 패턴 ──
# full_text 사용 (popups/details 내용 포함)
if relation_type and relation_type != "none" and full_text:
evidence = detect_relation_evidence(full_text)
claimed_count = evidence.get(relation_type, 0)
if claimed_count == 0:
# 주장한 관계의 증거가 0개
alternatives = [(k, v) for k, v in evidence.items() if v >= 2]
alt_str = ", ".join(f"{k}({v}개)" for k, v in alternatives[:3])
errors.append({
"severity": "RETRYABLE",
"field": f"topics[{tid}].relation_type",
"localization": f"topic {tid}: '{relation_type}' 증거 0개",
"evidence": f"원본에서 '{relation_type}' 패턴 없음. 대안: {alt_str}" if alt_str else f"원본에서 '{relation_type}' 패턴 없음",
"instruction": f"원본 텍스트에 '{relation_type}' 관계를 나타내는 표현이 없음. 재판단하라.",
})
return errors