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:
445
src/block_assembler.py
Normal file
445
src/block_assembler.py
Normal 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
557
src/block_reference.py
Normal 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
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
1040
src/fit_verifier.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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 제목 → ### 제목 (번호 제거)
|
||||
- * **제목** (## 전 도입부) → ## 승격
|
||||
-  → [이미지] 참조 보존
|
||||
- *이탤릭 출처* → 출처: 텍스트
|
||||
"""
|
||||
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
|
||||
|
||||
|
||||
@@ -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
434
src/mdx_normalizer.py
Normal 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
|
||||
1100
src/pipeline.py
1100
src/pipeline.py
File diff suppressed because it is too large
Load Diff
316
src/pipeline_context.py
Normal file
316
src/pipeline_context.py
Normal 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,
|
||||
)
|
||||
@@ -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>
|
||||
|
||||
@@ -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
780
src/step_visualizer.py
Normal 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("<", "<") + ("..." 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("<", "<") + ("..." 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
380
src/validators.py
Normal 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
|
||||
Reference in New Issue
Block a user