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:
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user