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>
1070 lines
43 KiB
Python
1070 lines
43 KiB
Python
"""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 제목 → ### 제목 (번호 제거)
|
||
- * **제목** (## 전 도입부) → ## 승격
|
||
-  → [이미지] 참조 보존
|
||
- *이탤릭 출처* → 출처: 텍스트
|
||
"""
|
||
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
|
||
|