Phase S: Claude HTML 직접 생성 + 독립 검증 시스템 도입

블록 선택 방식(Phase P/Q/R) 폐기 → Claude Sonnet이 영역별 HTML 직접 생성.
생성-검증 분리: content_verifier.py로 텍스트 보존/금지 콘텐츠/구조를 코드 검증.

주요 변경:
- src/html_generator.py: 4개 프롬프트 템플릿(BG/CORE/SIDEBAR/FOOTER) + 영역별 Claude 호출
- src/content_verifier.py: L1 텍스트 보존, L2 금지 콘텐츠, L3 구조 검증 + 재시도 루프
- src/html_validator.py: 보안 검증(script/iframe 제거)
- src/renderer.py: render_slide_from_html() 추가, area div overflow:hidden
- scripts/test_phase_s.py: generate_with_retry() 통합, step2b_verification 결과 저장
- 배경 라이트 디자인(#f8fafc), 개조식 어미 변환, 축약 금지 규칙

다음 과제: 폰트 위계(핵심14>본문12>배경10-12>첨부9-11) + 동적 컨테이너 계산

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-31 08:37:05 +09:00
parent 9410576e60
commit 0e4b8c091c
14 changed files with 3875 additions and 242 deletions

623
src/html_generator.py Normal file
View File

@@ -0,0 +1,623 @@
"""Phase S: AI HTML 생성기 — 검증 합격 프롬프트 템플릿 기반.
영역별 개별 호출. 검증에서 합격한 프롬프트의 구조/디자인은 고정, 텍스트만 동적.
역할 분리:
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__)
# ═══════════════════════════════════════════════════════════
# 검증 합격 프롬프트 템플릿
# 구조/디자인은 고정. {변수}만 동적 교체.
# ═══════════════════════════════════════════════════════════
BG_PROMPT = """다음 콘텐츠를 배경(보조) 영역 HTML로 만들어라.
## 핵심 원칙
이 영역은 **보조 영역**이다. 본심(핵심 콘텐츠)보다 시각적으로 약해야 한다.
다크 배경 절대 금지. 흰색/연회색 위에 텍스트를 놓는 라이트 디자인으로.
## 크기
- width: 100%, height: {height}px (고정, overflow:hidden)
## 콘텐츠 (축약/요약/삭제 금지. 원본 텍스트를 그대로 사용.)
{content_block}
## 텍스트 규칙 (반드시 적용)
1. 원본 텍스트의 단어를 한 글자도 빼지 마라. 축약/요약 절대 금지.
2. 마침표(.)로 끝나는 문장이 2개 이상이면 각각 별도 불릿(•)으로 분리.
3. 개조식 어미 변환: 문장 끝 1-2글자만 변환. 그 외 단어는 절대 건드리지 마라.
- "~있다""~있음", "~한다""~함", "~이다" → 삭제, "~된다""~됨"
예: "인식되고 있다""인식되고 있음" (단어 삭제 없이 끝만 변환)
4. 원본에 없는 텍스트를 추가하지 마라.
## 디자인
- 배경: 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
- 들여쓰기: 불릿 다음 줄은 불릿 옆 글자 위치에 맞춤
CSS: .bp {{ padding-left:14px; text-indent:-14px; }}
CSS: .bp::before {{ content:''; display:inline-block; width:14px; text-indent:0; }}
- 폰트를 줄여서라도 높이 안에 맞출 것. overflow:hidden이므로 넘치면 잘림.
- 모든 텍스트가 보여야 한다. 잘리는 텍스트가 있으면 안 됨.
HTML + inline <style>만 반환. 설명 없이 코드만."""
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: 11px;
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>
<span class="popup-link">📊 DX와 BIM의 상세 비교</span>
</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 class="sp"><b>하위 항목</b> : 설명</div>
</div>
<div class="key-msg">
<em>BIM ≠ DX</em> — 핵심 메시지 (analysis.core_message 사용)
</div>
주의: "상위개념", "하위기술", "포함관계" 같은 임의 라벨을 넣지 마라. core_message 텍스트를 그대로 사용.
</div>
```
## 핵심 메시지 (하단 key-msg 박스에 이 텍스트 그대로 사용)
{core_message}
## 콘텐츠 (축약/요약/삭제 금지. 원본 텍스트 80-95% 그대로 사용.)
{content_block}
## 텍스트 규칙 (반드시 적용)
1. 원본 텍스트의 단어를 한 글자도 빼지 마라. 축약/요약 절대 금지.
2. 마침표(.)로 끝나는 문장이 2개 이상이면 각각 별도 불릿(•)으로 분리.
3. 개조식 어미 변환: 문장 끝 1-2글자만 변환. 그 외 단어는 절대 건드리지 마라.
- "~있다""~있음", "~한다""~함", "~이다" → 삭제, "~된다""~됨"
예: "실현 가능한 상위개념이다""실현 가능한 상위개념" (끝의 "이다"만 삭제)
4. 원본에 없는 텍스트를 추가하지 마라.
{img_instruction}
## 팝업 비교표 데이터 (<details>/<summary>로 구현)
{popup_data}
## 절대 규칙
- "DX와 BIM의 상세 비교" 팝업 링크는 우측 상단에 1개만. 하단이나 다른 곳에 중복으로 넣지 마라.
- .core에 margin-top을 넣지 마라 (간격은 외부에서 처리됨). margin-top: 0 필수.
HTML + inline <style>만 반환. 위 CSS와 HTML 구조를 정확히 따르라. 설명 없이 코드만."""
SIDEBAR_PROMPT = """다음 용어 정의를 sidebar 카드로 만들어라. {width}px × {height}px.
## 용어 (축약/요약/삭제 금지. 원본 텍스트를 한 글자도 바꾸지 말고 그대로 사용.)
{definitions_block}
## 디자인 요구사항
1. 최상위 div: width:{width}px, height:{height}px, overflow:hidden (반드시 적용, 넘치면 잘림)
2. 상단에 "용어 정의" 구분선 라벨 (좌우 선 + 중앙 텍스트, 13px #64748b)
3. 각 용어를 카드로:
- 배경: #f8fafc, 테두리: 1px solid #e2e8f0, border-radius: 8px, padding: 14px
- 용어명: 14px bold #1e293b (예: "BIM (Building Information Modeling)")
- 부제 금지: 원본에 없는 텍스트를 만들어 넣지 마라. 용어명 아래에 임의 설명을 추가하지 마라.
- 불릿: 12px #475569, line-height: 1.6, 불릿 마커 ""
- 들여쓰기 CSS (반드시 적용):
```css
.def-bullet {{ padding-left: 14px; text-indent: -14px; margin-bottom: 4px; }}
.def-bullet::before {{ content: ''; display: inline-block; width: 14px; text-indent: 0; color: #475569; }}
```
- 각 불릿은 원본 텍스트 그대로. 단어를 한 글자도 빼지 마라.
- 마침표(.)로 끝나는 문장이 2개 이상이면 별도 불릿(•)으로 분리하라.
- 개조식 어미 변환: 문장 끝 1-2글자만 변환 ("~이다"→삭제, "~있다""~있음"). 그 외 단어 절대 건드리지 마라.
4. 카드 간 간격 10px
5. {height}px 안에 맞출 것. 넘치면 폰트를 줄여서 맞출 것.
HTML + inline <style>만 반환. 설명 없이 코드만."""
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>만 반환. 설명 없이 코드만."""
# ═══════════════════════════════════════════════════════════
# 메인 함수
# ═══════════════════════════════════════════════════════════
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 높이 계산 ──
# 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
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")
# ── 배경 ──
if bg_topics:
logger.info("[Phase S] 배경 생성...")
sections = _slice_mdx_sections(content)
bg_content = _map_sections_for_role(sections, bg_topics, ["혼용", "사례"])
prompt = BG_PROMPT.format(
height=bg_h,
content_block=bg_content,
)
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)}")
# ── 본심 ──
if core_topics:
logger.info("[Phase S] 본심 생성...")
core_content = _map_sections_for_role(sections, core_topics, ["관계", "핵심기술", "DX"])
popup = _get_popup_data(content)
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", ""),
content_block=core_content,
img_instruction=img_instruction,
popup_data=popup,
)
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)}")
# ── 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,
)
html = await _call_claude(client, prompt)
if html:
result["sidebar_html"] = html
logger.info(f"[Phase S] 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,
content_block=footer_content.strip(),
)
html = await _call_claude(client, prompt)
if html:
result["footer_html"] = html
logger.info(f"[Phase S] footer 완료: {len(html)}")
result["reasoning"] = "영역별 개별 호출, 검증 합격 프롬프트 템플릿 사용."
return result
# ═══════════════════════════════════════════════════════════
# 콘텐츠 추출 함수
# ═══════════════════════════════════════════════════════════
def _slice_mdx_sections(content: str) -> dict[str, str]:
"""원본 MDX를 ## 기준으로 섹션별 슬라이싱.
source_data(Kei 메모 포함)를 사용하지 않고,
원본 MDX 텍스트를 그대로 추출하여 프롬프트에 넣는다.
"""
sections = {}
current_section = None
current_lines = []
for line in content.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 _get_popup_data(content: str) -> str:
"""팝업 비교표 데이터."""
return """비교표 (<details>/<summary> 팝업으로):
| 기준 | DX | BIM |
| 범위 | BIM << DX (Engineering + Management 통합) | Only 3D (형상 구현 중심) |
| 프로세스 | 근본적 문제의식을 통한 개선 | 기존 2D 설계 방식 유지 |
| 활용 | 설계/시공 생산성 혁신 | 3D 모델에 의한 일반적 이해 향상 |
| 확장성 | 전 생애주기 활용 시스템 | (설계/시공/운영) 분야별 단절 |
| 주체 | 자체 수행 능력 — 지속가능성 확보 | S/W 제작사 판매 정책에 의존 |"""
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 60
sidebar_zone_h = 720 - 80 - 66 - footer_h - 40
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,
) + 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 = FOOTER_PROMPT.format(
height=concl_spec.height_px if concl_spec else 60,
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