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

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

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

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

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

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

View File

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