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:
623
src/html_generator.py
Normal file
623
src/html_generator.py
Normal 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
|
||||
Reference in New Issue
Block a user