Files
C.E.L_Slide_test2/src/html_generator.py
kyeongmin 1f7579cf64 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>
2026-04-06 05:00:52 +09:00

1070 lines
43 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Phase T: AI HTML 생성기 — 동적 프롬프트 생성.
영역별 개별 호출. Phase T context(폰트 위계, 블록 레퍼런스, 디자인 예산)에서
모든 수치를 동적으로 가져와 프롬프트를 조립.
Phase S 하드코딩 프롬프트(BG_PROMPT 등) → build_area_prompt() 동적 생성으로 교체.
역할 분리:
Kei (1단계): 콘텐츠 분석
Claude Sonnet (이 모듈): HTML 코드 생성
"""
from __future__ import annotations
import json
import logging
import re
from pathlib import Path
from typing import Any
import anthropic
from src.config import settings
logger = logging.getLogger(__name__)
# ═══════════════════════════════════════════════════════════
# Phase T: 동적 프롬프트 생성
# Phase S 하드코딩 프롬프트 → context 기반 동적 생성으로 교체
# ═══════════════════════════════════════════════════════════
# 공통 텍스트 규칙 (모든 영역 동일)
_COMMON_TEXT_RULES = """## 텍스트 규칙 (반드시 적용)
1. 원본 텍스트의 단어를 한 글자도 빼지 마라. 축약/요약 절대 금지.
2. 마침표(.)로 끝나는 문장이 2개 이상이면 각각 별도 불릿(•)으로 분리.
3. 개조식 어미 변환: 문장 끝 1-2글자만 변환. 그 외 단어는 절대 건드리지 마라.
- "~있다""~있음", "~한다""~함", "~이다" → 삭제, "~된다""~됨"
4. 원본에 없는 텍스트를 추가하지 마라.
5. 동일한 내용을 다른 형태로 2번 넣지 마라. 상세 내용은 "[상세보기]" 텍스트 링크만 남기고 본문에서 제거."""
# 공통 HTML 규칙
_COMMON_HTML_RULES = """## HTML 규칙
- inline style만 사용. <style> 블록 금지.
- overflow:hidden 금지 (텍스트 잘림 방지).
- 모든 텍스트가 보여야 한다. 잘리는 텍스트가 있으면 안 됨.
- 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 (반드시 적용)
## 참고할 CSS 구조 (이 CSS를 반드시 그대로 사용하라. 특히 .fi의 float:right는 절대 빼지 마라.):
```css
.core {{
width: 100%;
max-height: {height}px;
margin-top: 0;
font-family: 'Pretendard Variable', sans-serif;
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 14px 18px;
overflow: hidden;
word-break: keep-all;
}}
.core-header {{
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}}
.core-label {{
background: #1e293b;
color: #ffffff;
font-size: 12px;
font-weight: 700;
padding: 3px 12px;
border-radius: 4px;
}}
.popup-link {{
font-size: 10px;
color: #2563eb;
font-weight: 700;
cursor: pointer;
text-decoration: underline;
}}
.fi {{
float: right;
margin: {img_margin_top}px 0 8px 12px;
width: {img_width}px;
}}
.fi img {{ width: 100%; }}
.fi .cap {{
font-size: 9px;
color: #94a3b8;
text-align: center;
margin-top: 1px;
}}
.core-text {{
font-size: 12px;
color: #1e293b;
line-height: 1.75;
}}
.bp {{
padding-left: 14px;
text-indent: -14px;
margin-bottom: 5px;
}}
.bp::before {{
content: '';
display: inline-block;
width: 14px;
text-indent: 0;
color: #1e293b;
font-weight: 700;
}}
.sp {{
padding-left: 28px;
text-indent: -14px;
margin-bottom: 4px;
font-size: 11px;
color: #475569;
}}
.sp::before {{
content: '';
display: inline-block;
width: 14px;
text-indent: 0;
color: #64748b;
}}
.core-text b {{ font-weight: 700; color: #1e293b; }}
.key-msg {{
background: #f0f9ff;
border: 2px solid #bae6fd;
border-radius: 6px;
padding: 5px 12px;
text-align: center;
font-size: 14px;
font-weight: 700;
color: #0c4a6e;
margin-top: 8px;
clear: both;
}}
.key-msg em {{
color: #dc2626;
font-style: normal;
font-weight: 900;
}}
```
## 참고할 HTML 구조 (이 구조를 그대로 따르되 텍스트만 교체):
```html
<div class="core">
<div class="core-header">
<div class="core-label">제목 라벨</div>
</div>
<div class="core-text">
<div class="fi">
<img id="slide-img-ID" src="placeholder">
<div class="cap">이미지 캡션 (topic 제목을 사용)</div>
</div>
<div class="bp">메인 불릿 1</div>
<div class="bp">메인 불릿 2</div>
<div class="sp"><b>하위 항목</b> : 설명</div>
</div>
<div class="key-msg">
핵심 메시지 텍스트
</div>
</div>
```
## 핵심 메시지 (하단 key-msg 박스에 이 텍스트 그대로 사용. 임의 라벨/요약을 넣지 말고 아래 텍스트 그대로.)
{core_message}
## 콘텐츠 (축약/요약/삭제 금지. 원본 텍스트 80-95% 그대로 사용.)
{content_block}
## 텍스트 규칙 (반드시 적용)
1. 원본 텍스트의 단어를 한 글자도 빼지 마라. 축약/요약 절대 금지.
2. 마침표(.)로 끝나는 문장이 2개 이상이면 각각 별도 불릿(•)으로 분리.
3. 개조식 어미 변환: 문장 끝 1-2글자만 변환. 그 외 단어는 절대 건드리지 마라.
- "~있다""~있음", "~한다""~함", "~이다" → 삭제, "~된다""~됨"
예: "활용하는 과정이다""활용하는 과정" (끝만 변환)
4. 원본에 없는 텍스트를 추가하지 마라.
{img_instruction}
## 팝업 (<details>/<summary>)
원본 콘텐츠에 비교표/대조 구조가 있으면 <details>/<summary>로 팝업 구현하라.
없으면 팝업을 만들지 마라. 팝업 제목은 콘텐츠에 맞게 자동 결정.
팝업은 우측 상단에 1개만. 중복 금지.
## 절대 규칙
- .core에 margin-top을 넣지 마라 (간격은 외부에서 처리됨). margin-top: 0 필수.
- 원본에 없는 텍스트나 라벨을 임의로 넣지 마라.
HTML + inline <style>만 반환. 위 CSS와 HTML 구조를 정확히 따르라. 설명 없이 코드만."""
_LEGACY_SIDEBAR_PROMPT = """다음 용어 정의를 sidebar 카드로 만들어라. {width}px × {height}px.
## 용어 (축약/요약/삭제 금지. 원본 텍스트를 한 글자도 바꾸지 말고 그대로 사용.)
{definitions_block}
## 디자인 요구사항
1. 최상위 div: width:{width}px, height:{height}px, overflow:hidden (반드시 적용, 넘치면 잘림)
2. 상단에 "용어 정의" 라벨 (카드 좌측 시작점에 맞춰 좌측 정렬, 10px #64748b)
3. 각 용어를 카드로:
- 배경: #f8fafc, 테두리: 1px solid #e2e8f0, border-radius: 8px, padding: 14px
- 용어명: 11px bold #1e293b
- 부제 금지: 원본에 없는 텍스트를 만들어 넣지 마라. 용어명 아래에 임의 설명을 추가하지 마라.
- 불릿: 10px #475569, line-height: 1.6, 불릿 마커 ""
- 들여쓰기: 인라인 style만 사용 (CSS class 사용 금지).
각 불릿 div에 style="padding-left:14px; text-indent:-14px; margin-bottom:4px; font-size:10px; color:#475569; line-height:1.6;" 적용.
불릿 마커는 "" 텍스트로 직접 삽입 (::before 금지).
- 각 불릿은 원본 텍스트 그대로. 단어를 한 글자도 빼지 마라.
- 마침표(.)로 끝나는 문장이 2개 이상이면 별도 불릿(•)으로 분리하라.
- 개조식 어미 변환: 문장 끝 1-2글자만 변환 ("~이다"→삭제, "~있다""~있음"). 그 외 단어 절대 건드리지 마라.
4. 카드 간 간격 10px
5. {height}px 안에 맞출 것. 넘치면 폰트를 줄여서 맞출 것.
HTML만 반환. <style> 블록 금지. 모든 스타일은 인라인 style 속성으로. 설명 없이 코드만."""
_LEGACY_FOOTER_PROMPT = """결론 배너 HTML.
## 콘텐츠 (축약/요약/삭제 금지. 원본 텍스트를 그대로 사용.)
{content_block}
## 텍스트 규칙
- 원본 텍스트의 단어를 한 글자도 빼지 마라. 축약 절대 금지.
- 개조식 어미 변환: 문장 끝 1-2글자만 변환. 그 외 단어는 절대 건드리지 마라.
예: "일부분이다""일부분", "필요하다""필요" (끝만 변환, 앞 문장 그대로)
## 디자인
- 배너: linear-gradient(135deg, #006aff, #00aaff), border-radius: 8px
- 핵심 메시지: 15px bold white
- 부가 텍스트: 11px, opacity: 0.85
- padding: 14px 30px, text-align: center, height: {height}px
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 ""
# ═══════════════════════════════════════════════════════════
# 메인 함수
# ═══════════════════════════════════════════════════════════
async def generate_slide_html(
content: str,
analysis: dict[str, Any],
container_specs: dict,
preset: dict[str, Any],
images: list[dict] | None = None,
) -> dict[str, str]:
"""Phase S: 영역별 개별 호출, 검증 합격 프롬프트 템플릿 사용."""
if images is None:
images = []
client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
page_struct = analysis.get("page_structure", {})
topics = analysis.get("topics", [])
topic_map = {t["id"]: t for t in topics}
def get_topics_for_role(role: str) -> list[dict]:
info = page_struct.get(role, {})
if not isinstance(info, dict):
return []
return [topic_map[tid] for tid in info.get("topic_ids", []) if tid in topic_map]
bg_topics = get_topics_for_role("배경")
core_topics = get_topics_for_role("본심")
ref_topics = get_topics_for_role("첨부")
conclusion_topics = get_topics_for_role("결론")
bg_spec = container_specs.get("배경")
core_spec = container_specs.get("본심")
ref_spec = container_specs.get("첨부")
concl_spec = container_specs.get("결론")
result = {"body_html": "", "sidebar_html": "", "footer_html": "", "reasoning": ""}
# ── 실제 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 제외)")
# 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 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 T] 배경 완료: {len(html)}")
# ── 본심 ──
if core_topics:
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,
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 T] 본심 완료: {len(html)}")
# ── sidebar ──
if ref_topics:
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 T] sidebar 완료: {len(html)}")
# ── footer ──
if conclusion_topics:
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 T] footer 완료: {len(html)}")
result["reasoning"] = "영역별 개별 호출, 검증 합격 프롬프트 템플릿 사용."
return result
# ═══════════════════════════════════════════════════════════
# 콘텐츠 추출 함수
# ═══════════════════════════════════════════════════════════
def normalize_mdx(raw_mdx: str) -> str:
"""MDX를 ## 섹션 기반 표준 구조로 정규화 (0단계).
다양한 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 normalized.split("\n"):
if line.startswith("## "):
if current_section:
sections[current_section] = "\n".join(current_lines).strip()
current_section = line[3:].strip()
current_lines = []
elif current_section:
current_lines.append(line)
if current_section:
sections[current_section] = "\n".join(current_lines).strip()
return sections
def _map_sections_for_role(
sections: dict[str, str],
role_topics: list[dict],
fallback_keywords: list[str],
) -> str:
"""역할의 topics에 해당하는 원본 MDX 섹션을 매핑하여 반환.
1차: topic의 source_hint에서 섹션명 매칭
2차: fallback_keywords로 섹션명 검색
source_data는 사용하지 않음 (Kei 메모 포함 가능).
원본 MDX 텍스트만 반환.
"""
matched = []
matched_names = set()
# 1차: source_hint 기반 매칭
for t in role_topics:
hint = t.get("source_hint", "")
if hint:
for sec_name, sec_text in sections.items():
if sec_name in hint and sec_name not in matched_names:
matched.append(f"### {sec_name}")
matched.append(sec_text)
matched.append("")
matched_names.add(sec_name)
# 2차: fallback keywords
if not matched:
for sec_name, sec_text in sections.items():
if any(kw in sec_name for kw in fallback_keywords) and sec_name not in matched_names:
matched.append(f"### {sec_name}")
matched.append(sec_text)
matched.append("")
matched_names.add(sec_name)
# topic 메타정보 추가 (제목, 표현 의도 — source_data 제외)
meta = []
for t in role_topics:
meta.append(f"제목 라벨: \"{t.get('title', '')}\"")
hint = t.get("expression_hint", "")
if hint:
meta.append(f"표현 의도: {hint}")
result = "\n".join(meta) + "\n\n" + "\n".join(matched) if matched else "\n".join(meta)
return result.strip()
def _extract_keywords_from_hints(topics: list[dict]) -> list[str]:
"""source_hint에서 섹션명 키워드를 동적 추출.
예: "원본의 '용어의 혼용' 섹션 전체 내용" → ["용어의", "혼용"]
하드코딩 fallback 키워드를 대체한다.
"""
keywords = []
for t in topics:
hint = t.get("source_hint", "")
if not hint:
continue
# 따옴표 안의 섹션명 추출
match = re.search(r"['\"]([^'\"]+)['\"]", hint)
if match:
section_name = match.group(1)
words = [w for w in section_name.split() if len(w) >= 2]
keywords.extend(words)
else:
# 따옴표 없으면 hint에서 2글자 이상 단어 추출
words = [w for w in hint.split() if len(w) >= 2]
keywords.extend(words[:3]) # 최대 3개
return keywords
def _get_definitions(content: str) -> str:
"""용어 정의: 원본 MDX에서 용어별 정의 섹션을 그대로 추출."""
sections = _slice_mdx_sections(content)
for sec_name, sec_text in sections.items():
if "용어" in sec_name and "정의" in sec_name:
return sec_text
# fallback: "정의" 포함 섹션
for sec_name, sec_text in sections.items():
if "정의" in sec_name:
return sec_text
return "(원본에서 용어 정의를 찾지 못함)"
def _get_conclusion(content: str) -> str:
"""결론: 원본 MDX에서 핵심 요약/결론 섹션을 그대로 추출."""
sections = _slice_mdx_sections(content)
# "요약"이 포함된 섹션을 우선 매칭 (핵심만 포함된 섹션과 구분)
for sec_name, sec_text in sections.items():
if "요약" in sec_name:
return sec_text
for sec_name, sec_text in sections.items():
if "결론" in sec_name:
return sec_text
# fallback: 마지막 섹션
if sections:
return list(sections.values())[-1]
return ""
# ═══════════════════════════════════════════════════════════
# Claude 호출 + 이미지 교체
# ═══════════════════════════════════════════════════════════
async def _call_claude(client, prompt: str) -> str | None:
"""Claude Sonnet 호출 → HTML 추출."""
try:
response = await client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=8192,
messages=[{"role": "user", "content": prompt}],
)
text = response.content[0].text if response.content else ""
if not text:
return None
match = re.search(r"```html\s*(.*?)```", text, re.DOTALL)
if match:
return match.group(1).strip()
match = re.search(r"(<(?:div|style|section)[^>]*>.*)", text, re.DOTALL)
if match:
return match.group(1).strip()
return text.strip()
except Exception as e:
logger.error(f"[Phase S] Claude 호출 실패: {e}")
return None
async def regenerate_area(
area_name: str,
errors: list[str],
content: str,
analysis: dict[str, Any],
container_specs: dict,
preset: dict[str, Any],
images: list[dict] | None = None,
) -> str | None:
"""실패한 영역을 에러 피드백과 함께 재생성.
원래 프롬프트 끝에 검증 실패 사유를 추가하여 Claude에게 재생성 요청.
전체를 재생성하지 않고 해당 영역만 재생성.
"""
client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
# 에러 피드백 블록
error_feedback = "\n\n## 이전 생성 결과 검증 실패. 다음 문제를 반드시 수정하라:\n"
for i, err in enumerate(errors[:5], 1):
error_feedback += f"{i}. {err}\n"
error_feedback += "\n위 문제들을 해결한 새 HTML을 생성하라. 원본 텍스트를 축약/요약하지 마라."
sections = _slice_mdx_sections(content)
page_struct = analysis.get("page_structure", {})
topics = analysis.get("topics", [])
topic_map = {t["id"]: t for t in topics}
def get_topics_for_role(role: str) -> list[dict]:
info = page_struct.get(role, {})
if not isinstance(info, dict):
return []
return [topic_map[tid] for tid in info.get("topic_ids", []) if tid in topic_map]
if area_name == "sidebar":
ref_spec = container_specs.get("첨부")
# 실제 zone 높이 계산
footer_h = container_specs.get("결론")
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 = _LEGACY_SIDEBAR_PROMPT.format(
width=ref_spec.width_px if ref_spec else 0,
height=sidebar_zone_h,
definitions_block=defs,
) + error_feedback
logger.info(f"[재생성] sidebar 재생성 (에러 {len(errors)}건)")
return await _call_claude(client, prompt)
elif area_name == "footer":
concl_spec = container_specs.get("결론")
footer_content = _get_conclusion(content)
prompt = _LEGACY_FOOTER_PROMPT.format(
height=concl_spec.height_px if concl_spec else 0,
content_block=footer_content.strip(),
) + error_feedback
logger.info(f"[재생성] footer 재생성 (에러 {len(errors)}건)")
return await _call_claude(client, prompt)
else:
# body_bg, body_core는 합본이므로 None 반환 → 호출자가 전체 body 재생성
logger.info(f"[재생성] {area_name}은 body 전체 재생성 필요")
return None
def _replace_img_placeholder(html: str, images: list[dict]) -> str:
"""placeholder 이미지를 base64로 교체."""
for img in images:
b64 = img.get("b64", "")
if not b64:
continue
img_id = f"slide-img-{img.get('topic_id', 0)}"
if img_id in html:
data_uri = f"data:image/png;base64,{b64}"
html = html.replace('src="placeholder"', f'src="{data_uri}"')
html = html.replace("src='placeholder'", f"src='{data_uri}'")
logger.info(f"[Phase S] 이미지 교체: {img_id}")
return html