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

267
src/block_selector.py Normal file
View File

@@ -0,0 +1,267 @@
"""Phase Q-2: 제약 기반 블록 선택 엔진.
relation_type → 블록 카테고리 결정론적 매핑 + 컨테이너 제약 필터링 + catalog 검증.
AI에게 불가능한 선택지를 주지 않는다 (Beautiful.ai 원칙).
주요 함수:
- select_block_candidates(): topic + 컨테이너 → 물리적으로 가능한 후보 2-4개
- load_catalog(): catalog.yaml 로딩 + 캐싱
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Any
import yaml
from src.space_allocator import ContainerSpec, HEIGHT_COST_ORDER
logger = logging.getLogger(__name__)
CATALOG_PATH = Path("templates/catalog.yaml")
_catalog_cache: dict | None = None
_catalog_mtime: float = 0.0
# ──────────────────────────────────────
# relation_type → 블록 카테고리 매핑 (Napkin.ai 방식)
# ──────────────────────────────────────
RELATION_TO_CATEGORIES: dict[str, list[str]] = {
"hierarchy": ["visuals"],
"inclusion": ["visuals"],
"comparison": ["tables", "emphasis", "visuals"],
"sequence": ["visuals", "cards"],
"cause_effect": ["emphasis"],
"definition": ["cards"],
"none": ["emphasis", "cards"],
}
# sidebar에 배치할 수 없는 카테고리
SIDEBAR_FORBIDDEN_CATEGORIES = {"visuals", "media"}
# 블록이 콘텐츠 형태 변환을 강제하는 경우 (원문 보존도 저하)
# key: block_id, value: 강제하는 형태
# 원문이 서술형인데 이 블록이 선택되면 재작성이 불가피
BLOCKS_FORCING_FORMAT_CHANGE = {
"quote-question", # question 슬롯 필수 → 서술형 원문을 질문으로 변환 강제
}
# zone: full-width-only 블록은 sidebar에 배치 불가
# (catalog.yaml의 zone 필드로도 관리)
# ──────────────────────────────────────
# catalog.yaml 로딩 (mtime 캐시)
# ──────────────────────────────────────
def load_catalog() -> dict:
"""catalog.yaml을 로딩한다. mtime 기반 캐싱."""
global _catalog_cache, _catalog_mtime
if not CATALOG_PATH.exists():
logger.error(f"catalog.yaml 미발견: {CATALOG_PATH}")
return {"blocks": []}
current_mtime = CATALOG_PATH.stat().st_mtime
if _catalog_cache is not None and current_mtime == _catalog_mtime:
return _catalog_cache
with open(CATALOG_PATH, encoding="utf-8") as f:
_catalog_cache = yaml.safe_load(f)
_catalog_mtime = current_mtime
block_count = len(_catalog_cache.get("blocks", []))
logger.info(f"[Q-2] catalog.yaml 로딩: {block_count}개 블록")
return _catalog_cache
def _get_block_by_id(block_id: str, catalog: dict) -> dict | None:
"""catalog에서 블록 ID로 검색."""
for block in catalog.get("blocks", []):
if block.get("id") == block_id:
return block
return None
# ──────────────────────────────────────
# 핵심: 블록 후보 선택
# ──────────────────────────────────────
def select_block_candidates(
topic: dict[str, Any],
container_spec: ContainerSpec,
used_blocks: set[str],
catalog: dict | None = None,
) -> list[dict]:
"""topic + 컨테이너 → 물리적으로 가능한 블록 후보를 결정론적으로 필터링.
AI 호출 없음. 결과는 보통 2-4개.
Args:
topic: {"id", "title", "purpose", "relation_type", ...}
container_spec: 이 topic이 속한 컨테이너
used_blocks: 슬라이드 내 이미 사용된 블록 ID 집합
catalog: catalog.yaml 딕셔너리 (None이면 자동 로딩)
Returns:
[{"id": "venn-diagram", "category": "visuals", "min_height_px": 300, ...}, ...]
"""
if catalog is None:
catalog = load_catalog()
relation_type = topic.get("relation_type", "none")
categories = RELATION_TO_CATEGORIES.get(relation_type, ["emphasis", "cards"])
# topic당 가용 높이
topic_count_in_container = max(1, len(container_spec.topic_ids))
per_topic_px = container_spec.height_px // topic_count_in_container
candidates = []
for block in catalog.get("blocks", []):
block_id = block.get("id", "")
block_category = block.get("category", "")
# ── 필터 1: 카테고리 매칭 ──
if block_category not in categories:
continue
# ── 필터 2: headers 제외 (headers는 슬라이드 제목용) ──
if block_category == "headers":
continue
# ── 필터 3: 최소 생존 크기 (10% tolerance) ──
# 7px 차이로 가장 적합한 블록이 탈락하는 것을 방지
min_height = block.get("min_height_px", 0)
min_height_with_tolerance = min_height * 0.9
if min_height_with_tolerance > per_topic_px:
continue
# ── 필터 4: height_cost 범위 ──
block_cost = block.get("height_cost", "medium")
if HEIGHT_COST_ORDER.get(block_cost, 1) > HEIGHT_COST_ORDER.get(container_spec.max_height_cost, 3):
continue
# ── 필터 5: sidebar 제한 ──
if container_spec.zone == "sidebar":
if block_category in SIDEBAR_FORBIDDEN_CATEGORIES:
continue
if block.get("zone") == "full-width-only":
continue
# ── 필터 6: full-width-only 블록은 body/sidebar 나뉜 프리셋에서 body에만 ──
if block.get("zone") == "full-width-only" and container_spec.zone == "sidebar":
continue
# ── 필터 7: 중복 사용 제한 ──
if block_id in used_blocks:
continue
# ── 필터 8 (Phase Q fix): 형태 변환 강제 블록 제외 ──
# 원문 보존이 중요하므로, 콘텐츠 형태를 강제로 바꾸는 블록은 제외
if block_id in BLOCKS_FORCING_FORMAT_CHANGE:
continue
# ── 필터 9: relation_types 명시적 매칭 ──
# relation_types가 명시되어 있고 현재 relation_type이 포함 안 되면 제외
# (deprioritize가 아니라 exclude — 의미 왜곡 방지)
# 예: process-horizontal(sequence)은 hierarchy 콘텐츠에서 제외
block_relations = block.get("relation_types", [])
if block_relations and relation_type not in block_relations:
continue
# Phase R: 블록에 available variants 정보 첨부
variants = block.get("variants", [])
if variants:
block["_available_variants"] = variants
else:
block["_available_variants"] = [{"id": "default", "description": "기본"}]
candidates.append(block)
logger.info(
f"[Q-2] topic {topic.get('id')} (relation={relation_type}, "
f"container={per_topic_px}px): {len(candidates)}개 후보 "
f"[{', '.join(c['id'] for c in candidates[:5])}]"
)
return candidates
# ──────────────────────────────────────
# 폴백: 카테고리 제한 없이 검색
# ──────────────────────────────────────
def select_fallback_candidates(
container_spec: ContainerSpec,
used_blocks: set[str],
catalog: dict | None = None,
) -> list[dict]:
"""relation_type 매핑에서 후보가 없을 때, 물리적 제약만으로 검색.
최소 크기 + height_cost + zone만 검사. 카테고리 무시.
"""
if catalog is None:
catalog = load_catalog()
topic_count = max(1, len(container_spec.topic_ids))
per_topic_px = container_spec.height_px // topic_count
candidates = []
for block in catalog.get("blocks", []):
block_id = block.get("id", "")
if block.get("category") == "headers":
continue
if block.get("min_height_px", 0) > per_topic_px:
continue
if HEIGHT_COST_ORDER.get(block.get("height_cost", "medium"), 1) > HEIGHT_COST_ORDER.get(container_spec.max_height_cost, 3):
continue
if container_spec.zone == "sidebar" and block.get("zone") == "full-width-only":
continue
if block_id in used_blocks:
continue
candidates.append(block)
# compact 블록 우선 (작은 컨테이너에 적합)
cost_order = {"compact": 0, "medium": 1, "large": 2, "xlarge": 3}
candidates.sort(key=lambda c: cost_order.get(c.get("height_cost", "medium"), 1))
logger.info(
f"[Q-2 fallback] container={per_topic_px}px: {len(candidates)}개 후보"
)
return candidates
# ──────────────────────────────────────
# 후보 설명 텍스트 생성 (Kei 프롬프트용)
# ──────────────────────────────────────
def format_candidates_for_prompt(
candidates: list[dict],
budget: dict | None = None,
) -> str:
"""블록 후보 목록을 Kei 프롬프트에 포함할 텍스트로 포맷. Phase R: variant 정보 포함."""
lines = []
for i, c in enumerate(candidates[:5], 1): # 최대 5개
block_id = c.get("id", "")
name = c.get("name", "")
visual = c.get("visual", "")[:80] # 80자로 축약
height_cost = c.get("height_cost", "")
when = c.get("when", "")[:60]
budget_info = ""
if budget and block_id in budget:
b = budget[block_id]
budget_info = f" | 예산: 최대 {b['max_items']}항목, 총 {b['total_chars']}"
# Phase R: variant 정보
variants = c.get("_available_variants", [])
variant_lines = ""
if len(variants) > 1: # default만 있으면 표시 안 함
v_descs = [f" - {v['id']}: {v.get('description', '')}" for v in variants]
variant_lines = "\n 변형:\n" + "\n".join(v_descs)
lines.append(
f" {i}. {block_id} ({name})\n"
f" 시각: {visual}\n"
f" 적합: {when}\n"
f" 크기: {height_cost}{budget_info}{variant_lines}"
)
return "\n".join(lines)

635
src/content_verifier.py Normal file
View File

@@ -0,0 +1,635 @@
"""Phase S: 생성 HTML 콘텐츠 검증 + 재시도 루프.
생성기(html_generator)와 완전히 분리된 독립 검증.
코드 기반 검증을 먼저, LLM 검증은 코드가 못 잡는 것만.
검증 계층:
Layer 1: 텍스트 보존 검증 (코드, $0)
Layer 2: 금지 콘텐츠 검증 (코드, $0)
Layer 3: 구조 검증 (코드, $0)
Layer 4: 오버플로 검증 (Selenium, $0) — slide_measurer.py 재사용
Layer 5: 시각 품질 검증 (Opus 비전, $$) — kei_client.py 재사용
"""
from __future__ import annotations
import logging
import re
from dataclasses import dataclass, field
from difflib import SequenceMatcher
from html.parser import HTMLParser
logger = logging.getLogger(__name__)
# ═══════════════════════════════════════════════════════════
# 데이터 구조
# ═══════════════════════════════════════════════════════════
@dataclass
class VerificationResult:
"""단일 영역의 검증 결과."""
passed: bool
area_name: str
checks: dict[str, bool] = field(default_factory=dict)
score: float = 0.0
errors: list[str] = field(default_factory=list)
warnings: list[str] = field(default_factory=list)
# ═══════════════════════════════════════════════════════════
# HTML 텍스트 추출
# ═══════════════════════════════════════════════════════════
class _TextExtractor(HTMLParser):
"""HTML에서 가시 텍스트만 추출. <style>, <script> 내부 제외."""
def __init__(self):
super().__init__()
self.texts: list[str] = []
self._skip = False
def handle_starttag(self, tag, attrs):
if tag in ("style", "script"):
self._skip = True
def handle_endtag(self, tag):
if tag in ("style", "script"):
self._skip = False
def handle_data(self, data):
if not self._skip:
stripped = data.strip()
if stripped:
self.texts.append(stripped)
def extract_text_from_html(html: str) -> list[str]:
"""HTML에서 가시 텍스트를 추출하여 리스트로 반환."""
parser = _TextExtractor()
parser.feed(html)
return parser.texts
# ═══════════════════════════════════════════════════════════
# 텍스트 정규화 + 키워드 추출
# ═══════════════════════════════════════════════════════════
# 한국어 조사 (긴 것부터 매칭하도록 정렬)
_PARTICLES = sorted([
"에서", "으로", "부터", "까지", "에게", "한테",
"", "", "", "", "", "", "", "",
"", "", "", "", "", "",
], key=len, reverse=True)
# 개조식 어미 변환 매핑 (역변환: 개조식 → 서술형)
_ENDING_NORMALIZE = {
"있음": "있다",
"": "된다",
"": "한다",
"": "이다",
"없음": "없다",
"았음": "았다",
"었음": "었다",
"": "된다",
}
def normalize_for_comparison(text: str) -> str:
"""비교용 텍스트 정규화.
1. 공백/줄바꿈 통일
2. 불릿 마커 제거
3. HTML 엔티티 디코딩
4. 개조식 어미 → 서술형으로 통일 (양쪽 비교 기준 통일)
"""
# 공백 정규화
text = re.sub(r"\s+", " ", text).strip()
# 불릿 마커 제거
text = re.sub(r"[•◦·\-▪▸►]", "", text).strip()
# HTML 엔티티
text = text.replace("&amp;", "&").replace("&lt;", "<").replace("&gt;", ">")
text = text.replace("&nbsp;", " ").replace("&#39;", "'").replace("&quot;", '"')
# 개조식 어미 → 서술형 (비교 기준 통일)
for gaejo, seosul in _ENDING_NORMALIZE.items():
if text.endswith(gaejo):
text = text[: -len(gaejo)] + seosul
break
return text
def extract_keywords(text: str) -> list[str]:
"""3글자 이상 키워드 추출. 조사 제거."""
words = re.findall(r"[가-힣a-zA-Z0-9()]+", text)
keywords = []
for w in words:
if len(w) < 3:
continue
# 뒤쪽 조사 제거
for p in _PARTICLES:
if w.endswith(p) and len(w) - len(p) >= 2:
w = w[: -len(p)]
break
if len(w) >= 2:
keywords.append(w)
return keywords
# 검증에서 제외할 메타 라인 접두사 (Kei 분석 메타, 프롬프트 지시사항)
_META_PREFIXES = [
"제목 라벨:",
"표현 의도:",
"슬라이드 주인공",
"가장 큰 시각적 비중",
"시각적으로",
"간결하게 제기",
"개별 증거로 제시",
"계층적으로 시각화",
]
def strip_meta_lines(text: str) -> str:
"""검증 전에 메타/지시 라인을 제거.
_map_sections_for_role()이 추가하는 expression_hint, 제목 라벨 등은
Claude에게 보내는 지시사항이지 슬라이드에 들어갈 콘텐츠가 아니므로
검증 대상에서 제외한다.
"""
lines = text.split("\n")
filtered = []
for line in lines:
stripped = line.strip()
if any(stripped.startswith(prefix) for prefix in _META_PREFIXES):
continue
# expression_hint 내용도 제거 (문장 중간에 포함될 수 있음)
if "현상-문제 인과관계" in stripped:
continue
if "상위-하위 포함 관계" in stripped:
continue
if "독립적 나열" in stripped:
continue
filtered.append(line)
return "\n".join(filtered)
def split_into_sentences(text: str) -> list[str]:
"""텍스트를 문장 단위로 분할.
마침표, 줄바꿈, 불릿 기준 분할.
## 헤더, 빈 줄, 5자 미만, 메타 라인 필터링.
"""
# 메타 라인 제거
text = strip_meta_lines(text)
# 줄 단위 분할
lines = text.split("\n")
sentences = []
for line in lines:
line = line.strip()
if not line or line.startswith("#"):
continue
# 불릿 마커 제거
line = re.sub(r"^[\-•◦·\d]+[.)]\s*", "", line).strip()
if not line:
continue
# 마침표 기준 추가 분할
parts = re.split(r"(?<=\.)\s+", line)
for part in parts:
part = part.strip()
if len(part) >= 5:
sentences.append(part)
return sentences
# ═══════════════════════════════════════════════════════════
# Layer 1: 텍스트 보존 검증
# ═══════════════════════════════════════════════════════════
def verify_text_preservation(
original_mdx: str,
generated_html: str,
area_name: str,
threshold: float = 0.70,
) -> VerificationResult:
"""원본 MDX 텍스트가 HTML에 보존되었는지 검증.
1. 원본을 문장 단위로 분할
2. 각 문장의 키워드를 추출
3. HTML 텍스트에서 키워드 존재 확인
4. 문장별 매칭률 계산
"""
original_sentences = split_into_sentences(original_mdx)
if not original_sentences:
return VerificationResult(
passed=True, area_name=area_name,
checks={"text_preservation": True}, score=1.0,
)
html_texts = extract_text_from_html(generated_html)
html_combined = normalize_for_comparison(" ".join(html_texts))
matched = 0
missing: list[str] = []
for sentence in original_sentences:
norm_orig = normalize_for_comparison(sentence)
keywords = extract_keywords(norm_orig)
if not keywords:
matched += 1
continue
# 키워드 매칭률
kw_found = sum(1 for kw in keywords if kw in html_combined)
kw_ratio = kw_found / len(keywords)
# SequenceMatcher fallback
best_ratio = 0.0
for html_text in html_texts:
norm_html = normalize_for_comparison(html_text)
ratio = SequenceMatcher(None, norm_orig, norm_html).ratio()
if ratio > best_ratio:
best_ratio = ratio
if kw_ratio >= 0.6 or best_ratio >= 0.65:
matched += 1
else:
missing.append(sentence)
score = matched / len(original_sentences)
passed = score >= threshold
errors = []
if not passed:
errors = [f"누락 문장 ({len(missing)}/{len(original_sentences)}):"]
for s in missing[:5]: # 최대 5개만
errors.append(f" - \"{s[:60]}...\"" if len(s) > 60 else f" - \"{s}\"")
return VerificationResult(
passed=passed,
area_name=area_name,
checks={"text_preservation": passed},
score=score,
errors=errors,
warnings=[f"보존율: {score:.0%} ({matched}/{len(original_sentences)} 문장)"]
if score < 1.0 else [],
)
def detect_invented_text(
original_mdx: str,
generated_html: str,
min_length: int = 15,
) -> list[str]:
"""HTML에서 원본에 없는 발명된 텍스트를 탐지.
min_length 이상의 연속 텍스트가 원본에 없으면 발명 텍스트로 판정.
"""
# 허용 예외 (구조적 라벨)
allowed_labels = {
"용어 정의", "핵심 메시지", "상세 비교", "DX와 BIM의 상세 비교",
}
html_texts = extract_text_from_html(generated_html)
norm_mdx = normalize_for_comparison(original_mdx)
invented = []
for text in html_texts:
text = text.strip()
if len(text) < min_length:
continue
if text in allowed_labels:
continue
# CSS 값, 숫자만으로 된 것 제외
if re.match(r"^[\d\s.,%px#rgb()]+$", text):
continue
norm_text = normalize_for_comparison(text)
# 핵심 키워드 추출 후 원본에서 검색
keywords = extract_keywords(norm_text)
if not keywords:
continue
kw_found = sum(1 for kw in keywords if kw in norm_mdx)
kw_ratio = kw_found / len(keywords) if keywords else 1.0
if kw_ratio < 0.4:
invented.append(text[:80])
return invented
# ═══════════════════════════════════════════════════════════
# Layer 2: 금지 콘텐츠 검증
# ═══════════════════════════════════════════════════════════
FORBIDDEN_KEI_MEMOS = [
"간결한 문제 제기용",
"핵심 메시지만 추출",
"문제제기 핵심문장",
"source_data",
"expression_hint",
"relation_type",
]
FORBIDDEN_LABELS_IN_KEYMSG = [
"상위개념",
"하위기술",
"포함관계",
]
def verify_no_forbidden_content(
generated_html: str,
area_name: str,
) -> VerificationResult:
"""금지 콘텐츠가 HTML에 포함되지 않았는지 검증."""
html_text = " ".join(extract_text_from_html(generated_html))
found = []
# Kei 메모 검색
for memo in FORBIDDEN_KEI_MEMOS:
if memo in html_text:
found.append(f"Kei 메모 포함: \"{memo}\"")
# key-msg 영역의 금지 라벨 (body_core만)
if area_name == "body_core":
# key-msg 내용만 추출
keymsg_match = re.search(
r'class="key-msg"[^>]*>(.*?)</div>',
generated_html,
re.DOTALL,
)
if keymsg_match:
keymsg_text = keymsg_match.group(1)
for label in FORBIDDEN_LABELS_IN_KEYMSG:
if label in keymsg_text:
found.append(f"key-msg에 금지 라벨: \"{label}\"")
passed = len(found) == 0
return VerificationResult(
passed=passed,
area_name=area_name,
checks={"no_forbidden": passed},
score=1.0 if passed else 0.0,
errors=found,
)
# ═══════════════════════════════════════════════════════════
# Layer 3: 구조 검증
# ═══════════════════════════════════════════════════════════
REQUIRED_PATTERNS: dict[str, list[str]] = {
"body_bg": ["overflow:hidden", "overflow: hidden"],
"body_core": [
"overflow:hidden|overflow: hidden",
"float:right|float: right",
"key-msg",
"popup-link",
],
"sidebar": [
"overflow:hidden|overflow: hidden",
"padding-left",
"text-indent",
],
"footer": [],
}
def verify_structure(
generated_html: str,
area_name: str,
has_image: bool = False,
) -> VerificationResult:
"""필수 CSS/HTML 패턴이 존재하는지 검증."""
patterns = REQUIRED_PATTERNS.get(area_name, [])
missing = []
for pattern in patterns:
# OR 패턴: "a|b" → a 또는 b 중 하나 존재
alternatives = pattern.split("|")
if not any(alt in generated_html for alt in alternatives):
missing.append(pattern)
if has_image and area_name == "body_core":
if "slide-img-" not in generated_html:
missing.append("slide-img-* (이미지 태그)")
passed = len(missing) == 0
return VerificationResult(
passed=passed,
area_name=area_name,
checks={"structure": passed},
score=1.0 if passed else (1.0 - len(missing) / max(1, len(patterns))),
errors=[f"필수 패턴 누락: {p}" for p in missing],
)
# ═══════════════════════════════════════════════════════════
# 합성 검증
# ═══════════════════════════════════════════════════════════
def verify_area(
original_text: str,
generated_html: str,
area_name: str,
has_image: bool = False,
) -> VerificationResult:
"""단일 영역의 전체 검증 (L1 + L2 + L3)."""
results = [
verify_text_preservation(original_text, generated_html, area_name),
verify_no_forbidden_content(generated_html, area_name),
verify_structure(generated_html, area_name, has_image),
]
all_passed = all(r.passed for r in results)
all_checks = {}
all_errors = []
all_warnings = []
for r in results:
all_checks.update(r.checks)
all_errors.extend(r.errors)
all_warnings.extend(r.warnings)
avg_score = sum(r.score for r in results) / len(results)
return VerificationResult(
passed=all_passed,
area_name=area_name,
checks=all_checks,
score=avg_score,
errors=all_errors,
warnings=all_warnings,
)
def verify_all_areas(
generated: dict[str, str],
area_texts: dict[str, str],
has_image_areas: set[str] | None = None,
) -> dict[str, VerificationResult]:
"""모든 영역의 검증 결과를 반환.
Args:
generated: {"body_html": "...", "sidebar_html": "...", "footer_html": "..."}
area_texts: {"body_bg": "원본 텍스트", "body_core": "...", "sidebar": "...", "footer": "..."}
has_image_areas: 이미지가 있는 영역 이름 set
"""
if has_image_areas is None:
has_image_areas = set()
results = {}
# body_html은 bg + core 두 영역이 합쳐져 있으므로 분리 검증
body_html = generated.get("body_html", "")
if "body_bg" in area_texts and body_html:
results["body_bg"] = verify_area(
area_texts["body_bg"], body_html, "body_bg",
)
if "body_core" in area_texts and body_html:
results["body_core"] = verify_area(
area_texts["body_core"], body_html, "body_core",
has_image="body_core" in has_image_areas,
)
sidebar_html = generated.get("sidebar_html", "")
if "sidebar" in area_texts and sidebar_html:
results["sidebar"] = verify_area(
area_texts["sidebar"], sidebar_html, "sidebar",
)
footer_html = generated.get("footer_html", "")
if "footer" in area_texts and footer_html:
results["footer"] = verify_area(
area_texts["footer"], footer_html, "footer",
)
# 로그
for name, r in results.items():
status = "PASS" if r.passed else "FAIL"
logger.info(
f"[검증] {name}: {status} (score={r.score:.0%}, "
f"errors={len(r.errors)}, warnings={len(r.warnings)})"
)
for err in r.errors:
logger.warning(f"[검증] {name} 에러: {err}")
return results
# ═══════════════════════════════════════════════════════════
# 재시도 루프
# ═══════════════════════════════════════════════════════════
async def generate_with_retry(
content: str,
analysis: dict,
container_specs: dict,
preset: dict,
images: list[dict] | None = None,
max_retries: int = 2,
) -> tuple[dict[str, str], dict[str, VerificationResult]]:
"""검증 포함 생성 루프.
1. generate_slide_html() 호출
2. validate_and_clean_html() (보안)
3. verify_all_areas() (콘텐츠 검증)
4. 실패한 영역만 재생성 (에러 피드백 포함)
5. max_retries까지 반복
"""
from src.html_generator import generate_slide_html, regenerate_area, _slice_mdx_sections, _map_sections_for_role, _get_definitions, _get_conclusion
from src.html_validator import validate_and_clean_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]
area_texts = {}
bg_topics = get_topics_for_role("배경")
if bg_topics:
area_texts["body_bg"] = _map_sections_for_role(sections, bg_topics, ["혼용", "사례"])
core_topics = get_topics_for_role("본심")
if core_topics:
area_texts["body_core"] = _map_sections_for_role(sections, core_topics, ["관계", "핵심기술", "DX"])
ref_topics = get_topics_for_role("첨부")
if ref_topics:
area_texts["sidebar"] = _get_definitions(content)
conclusion_topics = get_topics_for_role("결론")
if conclusion_topics:
area_texts["footer"] = _get_conclusion(content)
has_image_areas = set()
if images:
core_topic_ids = {t["id"] for t in core_topics}
for img in images:
if img.get("topic_id") in core_topic_ids:
has_image_areas.add("body_core")
# 1차 생성
logger.info("[검증 루프] 1차 생성 시작")
generated = await generate_slide_html(
content=content, analysis=analysis,
container_specs=container_specs, preset=preset, images=images,
)
generated = validate_and_clean_html(generated)
# 검증 루프
for attempt in range(max_retries + 1):
verification = verify_all_areas(generated, area_texts, has_image_areas)
failed_areas = {name: r for name, r in verification.items() if not r.passed}
if not failed_areas:
logger.info(f"[검증 루프] 전체 PASS (시도 {attempt + 1}회)")
return generated, verification
if attempt >= max_retries:
logger.warning(
f"[검증 루프] {max_retries}회 재시도 후에도 실패: "
+ ", ".join(failed_areas.keys())
)
return generated, verification
# 실패 영역만 재생성
logger.info(
f"[검증 루프] 시도 {attempt + 1}: "
f"실패 영역 재생성 — {', '.join(failed_areas.keys())}"
)
for area_name, result in failed_areas.items():
new_html = await regenerate_area(
area_name=area_name,
errors=result.errors,
content=content,
analysis=analysis,
container_specs=container_specs,
preset=preset,
images=images,
)
if new_html:
# 영역별로 교체
if area_name in ("body_bg", "body_core"):
# body_html은 bg + core 합본이므로 전체 재생성 필요
# (개별 교체가 복잡하므로 body 전체를 재생성)
regenerated = await generate_slide_html(
content=content, analysis=analysis,
container_specs=container_specs, preset=preset,
images=images,
)
regenerated = validate_and_clean_html(regenerated)
generated["body_html"] = regenerated.get("body_html", generated["body_html"])
break # body 전체를 재생성했으므로 다른 body 영역도 갱신됨
elif area_name == "sidebar":
generated["sidebar_html"] = new_html
elif area_name == "footer":
generated["footer_html"] = new_html
return generated, verification

177
src/design_tokens.py Normal file
View File

@@ -0,0 +1,177 @@
"""Phase S-2: 디자인 토큰 + CSS 패턴 + 레이아웃 규칙을 프롬프트용으로 추출.
AI(Claude Sonnet)가 HTML을 생성할 때 참고하는 디자인 시스템 컨텍스트.
블록을 "선택"하는 것이 아니라, CSS 스타일을 "참고"하는 것.
Phase S 검증 결과 기반: 이미지 배치, 들여쓰기, 팝업 규칙 포함.
"""
from __future__ import annotations
from pathlib import Path
ROOT = Path(__file__).parent.parent
def load_design_tokens() -> str:
"""tokens.css 전문을 반환."""
tokens_path = ROOT / "static" / "tokens.css"
if tokens_path.exists():
return tokens_path.read_text(encoding="utf-8")
return ""
def load_base_css() -> str:
"""base.css 전문을 반환."""
base_path = ROOT / "static" / "base.css"
if base_path.exists():
return base_path.read_text(encoding="utf-8")
return ""
def get_block_css_patterns() -> str:
"""기존 블록에서 자주 쓰이는 CSS 패턴을 발췌하여 참고용 텍스트로 반환.
블록을 "선택"하기 위한 것이 아니라, AI가 HTML 생성 시 스타일을 "참고"하기 위한 것.
"""
return """
## 블록 CSS 패턴 참고 (스타일만 참고, 구조는 AI가 결정)
### 다크 배경 패턴
배경: linear-gradient(135deg, #1e293b, #0f172a)
텍스트: #ffffff, 제목: #93c5fd, 강조: #fbbf24
border-radius: 8px, padding: 14px 20px
불릿 마커: #60a5fa
카드 내부: rgba(255,255,255,0.06), border-left: 3px solid #60a5fa
### 밝은 카드 패턴
배경: #f8fafc, 테두리: 1px solid #e2e8f0
border-radius: 8px, padding: 12px 16px
제목: 15px bold #1e293b, 설명: 13px #475569
원형 번호: 32px, background: #2563eb, color: white
### 포함 관계 시각화 패턴
외곽 박스: border: 3px solid #2563eb, border-radius: 14px
배경: linear-gradient(180deg, #eff6ff, #dbeafe)
라벨 배지: background: #2563eb, color: white, border-radius: 10px, position: absolute top: -11px
내부 카드: background: white, border: 2px solid #93c5fd, border-radius: 8px
아이콘 원: background: linear-gradient(135deg, #93c5fd, #2563eb), 36px, border-radius: 50%
### 강조 메시지 패턴
배경: #f0f9ff, 테두리: 2px solid #bae6fd
텍스트: #0c4a6e, 강조 색상: #dc2626 (danger)
border-radius: 8px, padding: 10px 16px, text-align: center
### 배너/결론 패턴
배경: linear-gradient(135deg, #006aff, #00aaff)
텍스트: white, font-size: 15px bold
border-radius: 8px, padding: 14px 30px, text-align: center
### 테이블 패턴
헤더: background: #1e293b, color: white, padding: 8px 12px
행: border-bottom: 1px solid #e2e8f0, 짝수행: background: #f8fafc
첫 열: font-weight: 600, color: #1e293b
### 구분선 패턴
좌우 선: height: 1px, background: #cbd5e1
중앙 텍스트: 13px bold #64748b
### Sidebar 정의 카드 패턴
배경: #f8fafc, 테두리: 1px solid #e2e8f0
번호: 22px 원형, background: #2563eb
제목: 14px bold, 설명: 12px #475569
출처: 10px italic #94a3b8
"""
def get_layout_rules() -> str:
"""Phase S 검증 결과 기반 레이아웃 규칙."""
return """
## 레이아웃 규칙 (검증 결과 기반 — 반드시 따를 것)
### 들여쓰기 규칙 (절대 규칙)
- 불릿(•) 다음 줄은 반드시 불릿 옆 글자 시작 위치에 맞춤
- CSS: padding-left: 14px; text-indent: -14px;
- 하위 불릿(◦): padding-left: 28px; text-indent: -14px;
- 줄바꿈은 단어 기준 (word-break: keep-all)
### 이미지 배치 규칙
- 이미지가 있으면 float: right 또는 left로 텍스트와 어우러지게 (텍스트 감싸기)
- 이미지 크기: 컨테이너 너비의 30-40% (이미지 비율에 따라 판단)
- 이미지 border 없이 깔끔하게
- 이미지 바로 아래에 캡션 (이미지에 가까이 붙임, margin-top: 1-2px)
- 이미지 위치: 상단 텍스트 1-2줄은 전체 너비 → 그 아래에서 이미지 시작 (margin-top으로 조정)
- 이미지 아래 빈 공간에 관련 요소 배치 (팝업 링크, 부가 정보 등)
### 팝업 규칙
- 비교표, 상세 데이터 등은 <details>/<summary>로 팝업
- 팝업 링크: 상단 오른쪽에 텍스트 링크 (박스 불필요)
예: <summary>📊 DX와 BIM의 상세 비교</summary>
- 팝업 내용: position: absolute, background: white, box-shadow, z-index: 10
### 여백 규칙
- 빈 공간 최소화, 컨테이너를 꽉 채움
- 패딩/간격을 과도하게 넣지 않는다
- 이미지 아래 빈 공간에는 관련 요소(캡션, 팝업 링크 등) 배치
### 텍스트 보존 규칙 (절대 규칙)
- 원본 MDX의 80-95%를 그대로 사용
- source_data에 있는 텍스트를 그대로 가져온다
- 축약/요약/재작성 금지
- 삭제는 공간 부족 시에만, 뒤에서부터 자르기
- "상위개념", "하위기술" 같은 임의 라벨을 붙이지 않는다
"""
def build_design_context(container_specs: dict, images: list[dict] | None = None) -> str:
"""AI HTML 생성을 위한 전체 디자인 컨텍스트를 구성한다.
Args:
container_specs: 역할별 ContainerSpec
images: 이미지 정보 리스트 [{"path", "width", "height", "ratio", "topic_id"}, ...]
Returns:
AI 프롬프트에 포함할 디자인 시스템 텍스트
"""
if images is None:
images = []
tokens = load_design_tokens()
patterns = get_block_css_patterns()
# 컨테이너 정보
container_lines = []
for role, spec in container_specs.items():
container_lines.append(
f"- {role} ({spec.zone}): {spec.height_px}px × {spec.width_px}px, "
f"topics={spec.topic_ids}, weight={spec.weight}"
)
container_text = "\n".join(container_lines)
# 이미지 정보
image_text = ""
if images:
img_lines = []
for img in images:
img_lines.append(
f"- 경로: {img.get('path', '')}\n"
f" 크기: {img.get('width', '?')}px × {img.get('height', '?')}px\n"
f" 비율: {img.get('ratio', '?')} ({'가로형' if img.get('ratio', 1) > 1.5 else '세로형' if img.get('ratio', 1) < 0.7 else '정방형'})\n"
f" 소속 topic: {img.get('topic_id', '?')}"
)
image_text = "\n## 이미지 정보\n" + "\n".join(img_lines)
return f"""## 디자인 토큰 (CSS 변수 — 이 패턴을 참고)
{tokens}
## 슬라이드 프레임
- 크기: 1280px × 720px (고정)
- 폰트: Pretendard Variable
- 한국어 word-break: keep-all
- padding: 36px 40px 24px
## 컨테이너 스펙 (각 영역의 크기)
{container_text}
{image_text}
{patterns}
{get_layout_rules()}
"""

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

79
src/html_validator.py Normal file
View File

@@ -0,0 +1,79 @@
"""Phase R'-6: AI 생성 HTML 정화 + 토큰 위반 검증.
AI가 생성한 HTML에서:
1. 위험 태그 제거 (<script>, <iframe> 등)
2. CSS에 하드코딩 값이 있는지 경고 (토큰 위반)
"""
from __future__ import annotations
import logging
import re
logger = logging.getLogger(__name__)
def validate_and_clean_html(generated: dict[str, str]) -> dict[str, str]:
"""AI 생성 HTML을 정화하고 검증한다.
Args:
generated: {"body_html": "...", "sidebar_html": "...", "footer_html": "..."}
Returns:
정화된 generated dict
"""
cleaned = {}
for key in ("body_html", "sidebar_html", "footer_html"):
html = generated.get(key, "")
html = _remove_dangerous_tags(html)
_check_token_violations(html, key)
cleaned[key] = html
# reasoning은 그대로 유지
cleaned["reasoning"] = generated.get("reasoning", "")
return cleaned
def _remove_dangerous_tags(html: str) -> str:
"""위험 태그 제거."""
# <script>...</script> 제거
html = re.sub(r"<script[^>]*>.*?</script>", "", html, flags=re.DOTALL | re.IGNORECASE)
# <iframe> 제거
html = re.sub(r"<iframe[^>]*>.*?</iframe>", "", html, flags=re.DOTALL | re.IGNORECASE)
# on* 이벤트 핸들러 제거
html = re.sub(r'\s+on\w+="[^"]*"', "", html, flags=re.IGNORECASE)
html = re.sub(r"\s+on\w+='[^']*'", "", html, flags=re.IGNORECASE)
return html
def _check_token_violations(html: str, area_name: str) -> None:
"""CSS에서 디자인 토큰을 사용하지 않고 하드코딩한 값을 경고한다.
정보용 경고만. 생성을 차단하지는 않음 (AI가 블록 CSS 패턴을 참고하므로 하드코딩이 일부 있을 수 있음).
"""
# <style> 블록 추출
style_blocks = re.findall(r"<style[^>]*>(.*?)</style>", html, re.DOTALL | re.IGNORECASE)
if not style_blocks:
return
all_css = "\n".join(style_blocks)
# 허용된 하드코딩 색상 (블록 CSS 패턴에서 자주 쓰이는 것)
allowed_colors = {
"#1e293b", "#0f172a", "#2563eb", "#93c5fd", "#60a5fa",
"#ffffff", "#f8fafc", "#e2e8f0", "#475569", "#64748b",
"#94a3b8", "#cbd5e1", "#1e40af", "#bae6fd", "#f0f9ff",
"#006aff", "#00aaff", "#dc2626", "#fbbf24", "#0c4a6e",
"#334155", "#0088cc", "#86efac", "#fde047", "#16a34a",
"#fecaca", "#fef2f2",
}
# 하드코딩 색상 검출 (#으로 시작하는 hex)
found_colors = set(re.findall(r"#[0-9a-fA-F]{3,8}", all_css))
unknown_colors = found_colors - allowed_colors
if unknown_colors:
logger.info(
f"[R' 검증] {area_name}: 디자인 토큰 외 색상 {len(unknown_colors)}"
f"(참고: {list(unknown_colors)[:5]})"
)

View File

@@ -262,6 +262,240 @@ async def _call_kei_api(content: str) -> dict[str, Any] | None:
# ──────────────────────────────────────
# Phase Q-4: 제약 기반 블록 선택 (Kei 1회 호출)
# ──────────────────────────────────────
BLOCK_SELECTION_PROMPT = """당신은 11년 경력의 기획 실장이다. 각 꼭지(topic)에 가장 적합한 블록과 변형(variant)을 선택하라.
## 판단 기준 (우선순위 순)
1. 콘텐츠의 **표현 의도(expression_hint)**를 가장 잘 시각화하는 블록+변형
2. 이 꼭지의 목적(purpose)에 부합하는 표현 방식
3. 블록에 변형(variant)이 있으면, 콘텐츠에 더 적합한 변형을 선택
4. 글자수 예산 내에서 의미 전달이 가능한 블록
5. 다른 꼭지와 같은 블록 타입이 되지 않도록 다양성 확보
## 변형(variant) 선택 규칙
- 블록에 변형이 여러 개 있으면, "when" 조건과 expression_hint를 비교하여 적합한 것 선택
- 기본(default)이 적합하면 variant를 "default"로 지정
- 변형의 "when"이 expression_hint와 맞으면 해당 variant 선택
## 출력 (JSON, 꼭지 수만큼)
{"selections": [{"topic_id": 1, "block_id": "블록 id", "variant": "default 또는 변형 id", "reason": "선택 근거 1문장"}, ...]}
"""
async def select_block_for_topics(
topics: list[dict[str, Any]],
candidates_per_topic: dict[int, list[dict]],
budgets_per_topic: dict[int, dict[str, dict]],
container_specs: dict[str, Any],
analysis: dict[str, Any],
) -> dict[int, dict] | None:
"""Phase Q-4: 필터링된 후보 목록에서 Kei가 topic별 블록을 1개씩 선택.
AI 1회 호출로 모든 topic의 블록을 동시 선택한다.
Args:
topics: 1단계 분석의 topic 리스트
candidates_per_topic: {topic_id: [후보 블록 리스트]}
budgets_per_topic: {topic_id: {block_id: budget_dict}}
container_specs: 역할별 ContainerSpec
analysis: 1단계 분석 결과
Returns:
{topic_id: {"block_id": "...", "reason": "..."}} 또는 None (실패)
"""
from src.block_selector import format_candidates_for_prompt
from src.space_allocator import find_container_for_topic
core_message = analysis.get("core_message", "")
# 프롬프트 구성
prompt_parts = [
BLOCK_SELECTION_PROMPT,
f"\n## 슬라이드 핵심 메시지\n{core_message}\n",
]
for topic in topics:
tid = topic.get("id")
candidates = candidates_per_topic.get(tid, [])
if not candidates:
continue
spec = find_container_for_topic(tid, container_specs)
per_topic_px = spec.height_px // max(1, len(spec.topic_ids)) if spec else 200
budget = budgets_per_topic.get(tid, {})
expression_hint = topic.get("expression_hint", "")
prompt_parts.append(
f"\n### 꼭지 {tid}: {topic.get('title', '')}\n"
f"- 목적: {topic.get('purpose', '')}\n"
f"- 관계 유형: {topic.get('relation_type', 'none')}\n"
f"- ★ 표현 의도: {expression_hint}\n"
f"- 컨테이너: {per_topic_px}px × {spec.width_px if spec else 700}px "
f"({spec.role if spec else '?'} {spec.zone if spec else '?'})\n"
f"- 후보 블록:\n"
f"{format_candidates_for_prompt(candidates, budget)}\n"
)
full_prompt = "\n".join(prompt_parts)
kei_url = getattr(settings, "kei_api_url", "http://localhost:8000")
try:
async with httpx.AsyncClient(timeout=None) as client:
async with client.stream(
"POST",
f"{kei_url}/api/message",
json={
"message": full_prompt,
"session_id": "design-agent-q4",
"mode_hint": "chat",
},
timeout=None,
) as response:
if response.status_code != 200:
logger.warning(f"[Q-4] Kei API HTTP {response.status_code}")
return None
full_text = await stream_sse_tokens(response)
if not full_text:
logger.warning("[Q-4] Kei API 응답 비어있음")
return None
result = _parse_json(full_text)
if not result or "selections" not in result:
logger.warning(f"[Q-4] JSON 파싱 실패: {full_text[:200]}")
return None
# 결과 → {topic_id: {"block_id": ..., "reason": ...}}
selections = {}
for sel in result["selections"]:
tid = sel.get("topic_id")
block_id = sel.get("block_id", "")
# catalog 존재 검증 (유령 블록 최종 차단)
candidates = candidates_per_topic.get(tid, [])
valid_ids = {c.get("id") for c in candidates}
if block_id not in valid_ids:
logger.warning(
f"[Q-4] topic {tid}: Kei가 '{block_id}' 선택했으나 후보에 없음. "
f"첫 번째 후보로 대체."
)
block_id = candidates[0]["id"] if candidates else block_id
selections[tid] = {
"block_id": block_id,
"variant": sel.get("variant", "default"),
"reason": sel.get("reason", ""),
}
logger.info(
f"[Q-4] 블록 선택 완료: "
+ ", ".join(f"t{tid}={s['block_id']}" for tid, s in selections.items())
)
return selections
except Exception as e:
logger.error(f"[Q-4] Kei 블록 선택 실패: {e}")
return None
# ──────────────────────────────────────
# Phase Q-6: 비전 모델 품질 게이트 (VASCAR식)
# ──────────────────────────────────────
VISION_QUALITY_PROMPT = """슬라이드 스크린샷을 평가하라.
## 체크리스트 (각 항목 1-5점)
1. 콘텐츠 겹침/잘림: 모든 텍스트가 컨테이너 안에 있는가?
2. 시각적 위계: 본심 영역이 가장 두드러지는가?
3. 가독성: 모든 폰트가 읽을 수 있는 크기인가? (10px 이상)
4. 블록 다양성: 서로 다른 블록 유형을 사용하고 있는가?
5. 전문성: 한국어 비즈니스 프레젠테이션으로 적절한가?
## 출력 (JSON)
{
"passed": true/false,
"score": 0-100,
"checks": {"겹침": 5, "위계": 4, "가독성": 5, "다양성": 3, "전문성": 4},
"issues": ["문제 설명 (있으면)"],
"fix_targets": [{"area": "body", "topic_id": 3, "action": "shrink|replace|rewrite", "detail": "구체적 지시"}]
}
"""
async def vision_quality_gate(
screenshot_b64: str,
analysis: dict[str, Any],
) -> dict[str, Any] | None:
"""Phase Q-6: 스크린샷 기반 시각 품질 평가.
VASCAR 논문 기반 — 렌더링 → 비전 모델 평가 → 교정 여부 결정.
Returns:
{"passed": bool, "score": int, "issues": [...], "fix_targets": [...]}
"""
import anthropic
import base64
core_message = analysis.get("core_message", "")
topic_summary = ", ".join(
f"{t.get('id')}:{t.get('title','')}" for t in analysis.get("topics", [])
)
try:
client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
response = await client.messages.create(
model="claude-opus-4-0-20250514",
max_tokens=2048,
messages=[{
"role": "user",
"content": [
{
"type": "image",
"source": {
"type": "base64",
"media_type": "image/png",
"data": screenshot_b64,
},
},
{
"type": "text",
"text": (
VISION_QUALITY_PROMPT +
f"\n\n## 컨텍스트\n"
f"핵심 메시지: {core_message}\n"
f"꼭지 구성: {topic_summary}\n"
),
},
],
}],
)
result_text = response.content[0].text if response.content else ""
result = _parse_json(result_text)
if result:
score = result.get("score", 0)
passed = result.get("passed", score >= 50)
result["passed"] = passed
logger.info(
f"[Q-6] 품질 게이트: {score}/100 → {'PASS' if passed else 'FAIL'}"
)
return result
logger.warning(f"[Q-6] JSON 파싱 실패: {result_text[:200]}")
return None
except Exception as e:
logger.error(f"[Q-6] 비전 품질 평가 실패: {e}")
return None
# ──────────────────────────────────────
# J-7: Kei 최종 검수
# ──────────────────────────────────────
@@ -548,4 +782,111 @@ def _parse_json(text: str) -> dict[str, Any] | None:
return None
# manual_classify 삭제됨. Kei API는 필수. fallback 없음.
async def select_best_candidate(
topic_results: list[dict[str, Any]],
analysis: dict[str, Any],
) -> dict[str, Any]:
"""Phase P: Kei가 스크린샷을 보고 topic별 최적 블록을 선택한다.
여러 topic을 묶어서 1회 호출. 각 topic별 후보 스크린샷을 Opus 멀티모달로 비교.
Args:
topic_results: [{
"topic_id": 1,
"topic_title": "...",
"purpose": "문제제기",
"candidates": [
{"index": 0, "type": "callout-warning", "screenshot_b64": "...", "overflowed": False},
{"index": 1, "type": "dark-bullet-list", "screenshot_b64": "...", "overflowed": False},
{"index": 2, "type": "quote-big-mark", "screenshot_b64": "...", "overflowed": True},
]
}, ...]
analysis: 1단계 분석 결과 (core_message 등)
Returns:
{"selections": [{"topic_id": 1, "selected_index": 0, "reason": "..."}]}
"""
import anthropic
core_message = analysis.get("core_message", "")
# 메시지 content 블록 구성 (텍스트 + 이미지들)
content_blocks = []
# 지시문
instruction = (
f"슬라이드의 핵심 메시지: {core_message}\n\n"
"아래 각 꼭지(topic)별로 후보 블록 스크린샷을 보여준다.\n"
"각 꼭지마다 **당초 목적에 가장 적합한 1개**를 선택하라.\n\n"
"판단 기준:\n"
"1. 당초 목적(purpose)에 적합한가? (문제 제기인데 비교 블록이면 부적합)\n"
"2. 콘텐츠 의미가 왜곡되지 않는가?\n"
"3. 컨테이너에 넘치지 않는가? (overflow 표시된 것은 감점)\n"
"4. 같은 블록이 다른 topic과 중복되면 피하라.\n\n"
"전부 안 맞으면 그나마 가장 나은 것을 선택하라.\n\n"
)
# 각 topic의 후보 스크린샷 추가
for tr in topic_results:
tid = tr["topic_id"]
purpose = tr.get("purpose", "")
instruction += f"## 꼭지 {tid}: {tr.get('topic_title', '')} (목적: {purpose})\n"
for cand in tr.get("candidates", []):
idx = cand["index"]
block_type = cand.get("type", "")
overflowed = cand.get("overflowed", False)
overflow_note = " ⚠️ OVERFLOW" if overflowed else ""
instruction += f" 후보 {idx}: {block_type}{overflow_note}\n"
content_blocks.append({"type": "text", "text": instruction})
# 스크린샷 이미지 추가
for tr in topic_results:
for cand in tr.get("candidates", []):
b64 = cand.get("screenshot_b64")
if b64:
content_blocks.append({
"type": "text",
"text": f"[꼭지 {tr['topic_id']} 후보 {cand['index']}: {cand.get('type', '')}]",
})
content_blocks.append({
"type": "image",
"source": {
"type": "base64",
"media_type": "image/png",
"data": b64,
},
})
content_blocks.append({
"type": "text",
"text": (
"\n## 출력 (JSON만)\n"
'{"selections": [{"topic_id": 1, "selected_index": 0, "reason": "선택 이유"}]}'
),
})
try:
client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
response = await client.messages.create(
model="claude-opus-4-0-20250514",
max_tokens=2048,
messages=[{"role": "user", "content": content_blocks}],
)
result_text = response.content[0].text
result = _parse_json(result_text)
if result and "selections" in result:
logger.info(
f"[Phase P] Kei 최종 선택: "
+ ", ".join(f"t{s['topic_id']}→후보{s['selected_index']}" for s in result["selections"])
)
return result
logger.warning(f"[Phase P] 선택 JSON 파싱 실패: {result_text[:200]}")
return {"selections": []}
except Exception as e:
logger.warning(f"[Phase P] Kei 최종 선택 실패: {e}")
return {"selections": []}

View File

@@ -17,42 +17,53 @@ from typing import Any, AsyncIterator
import anthropic
from src.kei_client import classify_content, refine_concepts, call_kei_overflow_judgment, call_kei_final_review
from src.design_director import create_layout_concept, LAYOUT_PRESETS, select_preset
from src.content_editor import fill_content
from src.renderer import render_slide
from src.kei_client import classify_content, refine_concepts
from src.design_director import LAYOUT_PRESETS, select_preset
from src.image_utils import get_image_sizes, embed_images
from src.space_allocator import calculate_container_specs, finalize_block_specs, find_container_for_topic, calculate_trim_chars
from src.slide_measurer import measure_rendered_heights, format_measurement_for_kei, capture_slide_screenshot
from src.space_allocator import calculate_container_specs
from src.slide_measurer import measure_rendered_heights, capture_slide_screenshot
from src.config import settings
logger = logging.getLogger(__name__)
# Kei API 재시도 간격(초). 제한 없음 — 성공할 때까지 무한 재시도.
# Kei API 재시도 설정 (P0 수정: 무한 루프 방지)
KEI_RETRY_INTERVAL = 10
KEI_MAX_RETRY_ATTEMPTS = 30 # 최대 30회 (5분)
KEI_MAX_RETRY_DURATION = 300 # 절대 제한 300초
async def _retry_kei(fn, *args, **kwargs):
"""Kei API 호출을 성공할 때까지 무한 재시도한다.
"""Kei API 호출을 성공할 때까지 재시도한다.
Kei API는 필수 인프라. fallback 없음. 제한 없음.
10분이든 20분이든 Kei가 응답할 때까지 기다린다.
Kei API는 필수 인프라. fallback 없음.
최대 30회 또는 300초까지 재시도 후 TimeoutError.
"""
import asyncio
attempt = 0
while True:
start_time = time.time()
while attempt < KEI_MAX_RETRY_ATTEMPTS:
attempt += 1
elapsed = time.time() - start_time
if elapsed > KEI_MAX_RETRY_DURATION:
raise TimeoutError(
f"Kei API 타임아웃: {fn.__name__}"
f"{elapsed:.0f}초 경과 (제한 {KEI_MAX_RETRY_DURATION}초)"
)
result = await fn(*args, **kwargs)
if result is not None:
if attempt > 1:
logger.info(f"[Kei 재시도] {fn.__name__} 성공 ({attempt}번째 시도)")
return result
logger.warning(
f"[Kei 재시도] {fn.__name__} 실패 (시도 {attempt}). "
f"[Kei 재시도] {fn.__name__} 실패 (시도 {attempt}/{KEI_MAX_RETRY_ATTEMPTS}). "
f"{KEI_RETRY_INTERVAL}초 후 재시도..."
)
await asyncio.sleep(KEI_RETRY_INTERVAL)
raise RuntimeError(
f"Kei API 최대 재시도 초과: {fn.__name__}{attempt}회 시도"
)
def _save_step(run_dir: Path, filename: str, data: Any) -> None:
"""스텝 결과를 JSON 또는 HTML로 저장한다. (K-1)"""
@@ -150,238 +161,110 @@ async def generate_slide(
for role, spec in container_specs.items()
})
# 2단계: 디자인 팀장 — Step A(프리셋) + Step A-2(Kei 블록 확정) + Step B(zone 배치)
yield {"event": "progress", "data": "2/5 디자인 팀장이 레이아웃을 설계 중..."}
# ★ Phase S: Claude Sonnet이 HTML 직접 생성
# 블록 선택 없음. 슬롯 채우기 없음. AI가 콘텐츠에 맞는 HTML 구조를 직접 만든다.
yield {"event": "progress", "data": "2/4 슬라이드 HTML 생성 중..."}
layout_concept = await create_layout_concept(content, analysis, container_specs=container_specs)
total_blocks = sum(
len(p.get("blocks", [])) for p in layout_concept.get("pages", [])
)
logger.info(
f"2단계 완료: {len(layout_concept.get('pages', []))}페이지, "
f"{total_blocks}개 블록"
)
_save_step(run_dir, "step2_layout.json", {
"preset": layout_concept.get("pages", [{}])[0].get("grid_areas", ""),
"blocks": [
{
"area": b.get("area"), "type": b.get("type"),
"topic_id": b.get("topic_id"), "purpose": b.get("purpose"),
"reason": b.get("reason", ""), "size": b.get("size", ""),
}
for p in layout_concept.get("pages", [])
for b in p.get("blocks", [])
],
"overflow": layout_concept.get("overflow", []),
})
# ★ Phase O-3: 블록 스펙 확정 (컨테이너 크기 → 항목수/글자수/폰트)
for page in layout_concept.get("pages", []):
finalize_block_specs(page.get("blocks", []), container_specs)
# 컨테이너 스펙을 layout_concept에 저장 (렌더러에서 사용)
layout_concept["_container_specs"] = container_specs
_save_step(run_dir, "step2c_block_specs.json", {
"blocks": [
{
"type": b.get("type"), "topic_id": b.get("topic_id"),
"area": b.get("area"),
"_container_height_px": b.get("_container_height_px"),
"_max_items": b.get("_max_items"),
"_max_chars_per_item": b.get("_max_chars_per_item"),
"_max_chars_total": b.get("_max_chars_total"),
"_font_size_px": b.get("_font_size_px"),
}
for p in layout_concept.get("pages", [])
for b in p.get("blocks", [])
]
})
# 3단계: 텍스트 편집자 — 텍스트 정리
yield {"event": "progress", "data": "3/5 텍스트 편집자가 핵심을 정리 중..."}
layout_concept = await fill_content(content, layout_concept, analysis)
logger.info("3단계 완료: 텍스트 정리")
_save_step(run_dir, "step3_filled_blocks.json", {
"blocks": [
{
"area": b.get("area"), "type": b.get("type"),
"topic_id": b.get("topic_id"), "purpose": b.get("purpose"),
"data": b.get("data", {}),
"char_count": len(json.dumps(b.get("data", {}), ensure_ascii=False)),
}
for p in layout_concept.get("pages", [])
for b in p.get("blocks", [])
]
})
# 4단계: 디자인 실무자 — 디자인 조정 + HTML 조립
yield {"event": "progress", "data": "4/5 디자인 실무자가 슬라이드를 조립 중..."}
layout_concept = await _adjust_design(layout_concept, analysis)
html = render_slide(layout_concept)
logger.info("4단계 완료: HTML 조립")
_save_step(run_dir, "step4_css_adjustment.json", {
"area_styles": layout_concept.get("pages", [{}])[0].get("area_styles", {})
})
_save_step(run_dir, "step4_rendered.html", html)
# Phase L: 렌더링 측정 + 피드백 루프 (최대 3회)
from src.html_generator import generate_slide_html
from src.html_validator import validate_and_clean_html
from src.renderer import render_slide_from_html
from src.kei_client import vision_quality_gate
import asyncio
MAX_MEASURE_ROUNDS = 3
measurement = None
for measure_round in range(MAX_MEASURE_ROUNDS):
measurement = await asyncio.to_thread(measure_rendered_heights, html)
_save_step(run_dir, f"step4_measurement_round{measure_round + 1}.json", measurement)
topics = analysis.get("topics", [])
# overflow 감지 — zone + container 양쪽 체크
has_overflow = False
for zone_name, zone_data in measurement.get("zones", {}).items():
if zone_data.get("overflowed"):
has_overflow = True
break
# Phase O: container 레벨 overflow도 체크
for cont_name, cont_data in measurement.get("containers", {}).items():
if cont_data.get("overflowed"):
has_overflow = True
logger.warning(
f"[측정] container-{cont_name}: "
f"scroll={cont_data.get('scrollHeight')}px > "
f"allocated={cont_data.get('allocatedHeight')}px "
f"(+{cont_data.get('excess_px')}px)"
)
break
# 이미지 정보 구성 (base64 포함)
slide_images = []
if image_sizes:
import base64 as b64_mod
for img_key, img_info in image_sizes.items():
img_path = Path(base_path) / img_key if base_path else Path(img_key)
img_b64 = ""
if img_path.exists():
img_b64 = b64_mod.b64encode(img_path.read_bytes()).decode()
slide_images.append({
"path": str(img_path),
"width": img_info.get("width", 0),
"height": img_info.get("height", 0),
"ratio": round(img_info.get("width", 1) / max(1, img_info.get("height", 1)), 2),
"topic_id": img_info.get("topic_id"),
"b64": img_b64,
})
if not has_overflow:
logger.info(f"[측정] 모든 zone/container 정상 (round {measure_round + 1})")
break
# Claude Sonnet이 HTML 생성
generated = await generate_slide_html(
content=content,
analysis=analysis,
container_specs=container_specs,
preset=preset,
images=slide_images,
)
logger.warning(f"[측정] overflow 감지 (round {measure_round + 1})")
# HTML 정화 + 검증
generated = validate_and_clean_html(generated)
# 수학적 축약량 계산 → 편집자 재호출
adjusted = False
for zone_name, zone_data in measurement.get("zones", {}).items():
if not zone_data.get("overflowed"):
continue
excess = zone_data.get("excess_px", 0)
zone_info = preset.get("zones", {}).get(zone_name, {})
width_px = int(settings.slide_width * zone_info.get("width_pct", 100) / 100 * 0.85)
_save_step(run_dir, "step2_generated.json", {
"body_html_length": len(generated.get("body_html", "")),
"sidebar_html_length": len(generated.get("sidebar_html", "")),
"footer_html_length": len(generated.get("footer_html", "")),
"reasoning": generated.get("reasoning", ""),
})
logger.info(
f"[Phase S] HTML 생성 완료: body={len(generated.get('body_html', ''))}자, "
f"sidebar={len(generated.get('sidebar_html', ''))}자, "
f"footer={len(generated.get('footer_html', ''))}"
)
# Phase O: overflow 블록의 _max_chars_total 축소
for block_m in zone_data.get("blocks", []):
if block_m.get("overflowed"):
trim_chars = calculate_trim_chars(
block_m.get("excess_px", excess),
width_px,
)
for page in layout_concept.get("pages", []):
for block in page.get("blocks", []):
if block.get("area") == zone_name:
current_max = block.get("_max_chars_total", 400)
block["_max_chars_total"] = max(20, current_max - trim_chars)
if "data" in block:
del block["data"]
adjusted = True
logger.info(
f"[측정 조정] {zone_name}/{block_m.get('block_type')}: "
f"{block_m.get('excess_px')}px 초과 → "
f"_max_chars_total {current_max}{block['_max_chars_total']} ({trim_chars}자 축약)"
)
break
# 3단계: 렌더링 — AI 생성 HTML을 슬라이드 프레임에 삽입
yield {"event": "progress", "data": "3/4 슬라이드 조립 중..."}
if not adjusted:
logger.info("[측정] 조정 대상 없음, 현재 결과 확정")
break
html = render_slide_from_html(generated, analysis, preset)
logger.info("[Phase S] 슬라이드 조립 완료")
_save_step(run_dir, "step3_rendered.html", html)
# 편집자 재호출 → 재렌더링
layout_concept = await fill_content(content, layout_concept, analysis)
layout_concept = await _adjust_design(layout_concept, analysis)
html = render_slide(layout_concept)
logger.info(f"[측정] round {measure_round + 1} 재렌더링 완료")
# ★ Phase Q: 검증 렌더링 + 수학적 조정 + 비전 품질 게이트
measurement = await asyncio.to_thread(measure_rendered_heights, html)
_save_step(run_dir, "step4_measurement.json", measurement)
# 측정 결과 텍스트 (Kei 검수에 전달)
measurement_text = format_measurement_for_kei(measurement) if measurement else ""
# Phase S: overflow 감지
has_overflow = False
for zone_name, zone_data in measurement.get("zones", {}).items():
if zone_data.get("overflowed"):
has_overflow = True
logger.warning(f"[Phase S] {zone_name}: overflow +{zone_data.get('excess_px', 0)}px")
# Phase N-4: 5단계 — Kei 실장 최종 검수 (스크린샷 기반, 최대 1회)
# overflow 없으면 skip (시간 절약)
has_any_overflow = False
if measurement:
for zone_data in measurement.get("zones", {}).values():
if zone_data.get("overflowed"):
has_any_overflow = True
break
if measurement.get("slide", {}).get("overflowed"):
has_any_overflow = True
MAX_REVIEW_ROUNDS = 1
screenshot_b64 = None
if not has_any_overflow:
logger.info("5단계 skip: overflow 없음. 검수 불필요.")
if has_overflow:
logger.warning("[Phase S] overflow 감지 — 결과물에 반영 (후속 품질 게이트에서 평가)")
else:
yield {"event": "progress", "data": "5/5 Kei 실장이 최종 검수 중..."}
logger.info("[Phase S] overflow 없음")
# 스크린샷 캡처 (Selenium)
screenshot_b64 = await asyncio.to_thread(capture_slide_screenshot, html)
if screenshot_b64:
_save_step(run_dir, "step5_screenshot.txt", f"base64 PNG, {len(screenshot_b64)} chars")
logger.info("[5단계] 스크린샷 캡처 완료 → Kei에게 전달")
# Phase S: 비전 모델 품질 게이트
yield {"event": "progress", "data": "4/4 품질 검증 중..."}
for review_round in range(MAX_REVIEW_ROUNDS if has_any_overflow else 0):
review_result = await _review_balance(
html, layout_concept, content, analysis, measurement_text,
screenshot_b64=screenshot_b64,
)
screenshot_b64 = await asyncio.to_thread(capture_slide_screenshot, html)
quality_result = None
if not review_result or not review_result.get("needs_adjustment"):
if review_round == 0:
logger.info("5단계 완료: 조정 불필요")
if screenshot_b64:
_save_step(run_dir, "step5_screenshot.txt", f"base64 PNG, {len(screenshot_b64)} chars")
quality_result = await vision_quality_gate(screenshot_b64, analysis)
if quality_result:
_save_step(run_dir, "step5_quality_gate.json", quality_result)
if not quality_result.get("passed", True):
score = quality_result.get("score", 0)
issues = quality_result.get("issues", [])
logger.warning(f"[Q-6] 품질 게이트 FAIL: {score}/100 — {issues}")
# Q-8: 심각한 품질 문제 시 출력 차단
if score < 30:
logger.error(f"[Q-8] 출력 차단: 품질 {score}/100 < 30 최소 기준")
yield {"event": "error", "data": f"슬라이드 품질 미달 ({score}/100). 재시도해 주세요."}
return
else:
logger.info(f"5단계 완료: {review_round}차 조정 후 균형 확인")
break
issues = review_result.get("issues", [])
logger.info(
f"5단계 ({review_round + 1}/{MAX_REVIEW_ROUNDS}): "
f"조정 필요 — {issues}"
)
_save_step(run_dir, f"step5_review_round{review_round + 1}.json", review_result)
# overflow_detected가 있으면 Kei에게 판단 요청 (Sonnet은 감지만, 판단은 Kei)
overflow_adjs = [
adj for adj in review_result.get("adjustments", [])
if adj.get("action") == "overflow_detected"
]
if overflow_adjs:
overflow_context = _build_overflow_context(
layout_concept, overflow_adjs
)
kei_judgment = await call_kei_overflow_judgment(
overflow_context, content, analysis
)
if kei_judgment is None:
# 넘침 판단도 Kei 필수 — 성공할 때까지 무한 재시도
kei_judgment = await _retry_kei(
call_kei_overflow_judgment, overflow_context, content, analysis
)
_convert_kei_judgment(review_result, kei_judgment)
logger.info(
f"[Kei 넘침 판단] decision={kei_judgment.get('decision')}"
)
layout_concept = await _apply_adjustments(
layout_concept, review_result, content
)
html = render_slide(layout_concept)
logger.info(f"5단계: {review_round + 1}차 조정 반영, 재검토 진행")
logger.info(f"[Q-6] 품질 게이트 PASS: {quality_result.get('score', 0)}/100")
else:
# MAX_REVIEW_ROUNDS 초과
logger.warning(
f"5단계: 최대 재조정 횟수({MAX_REVIEW_ROUNDS}) 도달. 현재 결과로 확정."
)
logger.warning("[Q-6] 스크린샷 캡처 실패 — 품질 게이트 스킵")
# D-5: 이미지를 base64로 삽입 (다운로드 HTML에서도 보이도록)
if base_path:
@@ -390,7 +273,7 @@ async def generate_slide(
_save_step(run_dir, "final.html", html)
yield {"event": "result", "data": html}
logger.info(f"슬라이드 생성 완료: {len(layout_concept.get('pages', []))}페이지, run={run_id}")
logger.info(f"슬라이드 생성 완료: run={run_id}")
except Exception as e:
logger.exception(f"파이프라인 오류: {e}")
@@ -558,7 +441,8 @@ async def _review_balance(
if measurement_text:
overflow_hint_text += f"\n\n{measurement_text}"
# Kei로 최종 검수 (Sonnet 절대 금지, 스크린샷 있으면 이미지 기반)
# Kei로 최종 검수 (레거시 — Phase S에서는 메인 흐름에서 미사용)
from src.kei_client import call_kei_final_review
return await call_kei_final_review(
html, block_summary, zone_budget_text, overflow_hint_text, analysis,
screenshot_b64=screenshot_b64,
@@ -635,7 +519,8 @@ async def _apply_adjustments(
f"조정: {area} → kei_restructure (detail_target)"
)
# 조정된 가이드로 재편집
# 조정된 가이드로 재편집 (레거시 — Phase S에서는 미사용)
from src.content_editor import fill_content
layout_concept = await fill_content(content, layout_concept)
return layout_concept

View File

@@ -62,6 +62,41 @@ def _load_catalog_map() -> dict[str, str]:
return _CATALOG_MAP
# Phase R: variant별 template 경로 캐시
_CATALOG_VARIANT_MAP: dict[str, str] | None = None
def _load_catalog_map_with_variants() -> dict[str, str]:
"""catalog.yaml에서 variant별 template 경로 매핑을 로드한다.
키: "block_id--variant_id" → 값: template 경로
"""
global _CATALOG_VARIANT_MAP
# _load_catalog_map이 이미 캐시 관리하므로 같은 mtime 사용
_load_catalog_map() # 캐시 갱신 보장
if _CATALOG_VARIANT_MAP is not None and _CATALOG_MTIME == (CATALOG_PATH.stat().st_mtime if CATALOG_PATH.exists() else 0.0):
return _CATALOG_VARIANT_MAP
_CATALOG_VARIANT_MAP = {}
if CATALOG_PATH.exists():
try:
with open(CATALOG_PATH, encoding="utf-8") as f:
catalog = yaml.safe_load(f)
for block in catalog.get("blocks", []):
block_id = block.get("id", "")
for variant in block.get("variants", []):
vid = variant.get("id", "default")
vtemplate = variant.get("template", "")
if vid != "default" and vtemplate:
_CATALOG_VARIANT_MAP[f"{block_id}--{vid}"] = vtemplate
except Exception as e:
logger.warning(f"catalog variant 로드 실패: {e}")
return _CATALOG_VARIANT_MAP
def create_jinja_env() -> Environment:
"""Jinja2 환경 생성."""
return Environment(
@@ -70,19 +105,33 @@ def create_jinja_env() -> Environment:
)
def _resolve_template_path(env: Environment, block_type: str) -> str | None:
"""블록 타입로 템플릿 경로를 찾는다.
def _resolve_template_path(env: Environment, block_type: str, variant: str = "default") -> str | None:
"""블록 타입 + variant로 템플릿 경로를 찾는다.
Phase R: variant가 지정되면 variant 전용 템플릿을 우선 탐색.
variant 템플릿이 없으면 기존 블록 템플릿으로 fallback.
검색 순서:
0. catalog.yaml 매핑 (id → template 경로, 최우선)
1. 정확한 경로 (blocks/cards/card-icon-desc.html 등 — 팀장이 카테고리 포함 지정)
0-v. catalog.yaml의 variant별 template 경로 (Phase R, 최우선)
0. catalog.yaml 매핑 (id → template 경로)
1. 정확한 경로 (blocks/cards/card-icon-desc.html 등)
2. 카테고리 폴더 검색 (blocks/{category}/{block_type}.html)
3. _legacy fallback (blocks/_legacy/{block_type}.html)
4. 루트 fallback (blocks/{block_type}.html)
"""
candidates = []
# 0. catalog.yaml에서 id → template 매핑 조회 (최우선)
# 0-v. Phase R: variant 전용 템플릿 우선 탐색
if variant and variant != "default":
catalog_map_full = _load_catalog_map_with_variants()
variant_key = f"{block_type}--{variant}"
if variant_key in catalog_map_full:
candidates.append(catalog_map_full[variant_key])
# 카테고리 폴더에서 variant 파일 탐색
for category in BLOCK_CATEGORIES:
candidates.append(f"blocks/{category}/{block_type}--{variant}.html")
# 0. catalog.yaml에서 id → template 매핑 조회
catalog_map = _load_catalog_map()
if block_type in catalog_map:
catalog_path = catalog_map[block_type]
@@ -272,7 +321,7 @@ def render_multi_page(layout_concept: dict[str, Any]) -> str:
block_data = _preprocess_svg_data(block_type, block_data)
# DA-21: 카테고리 경로 검색
template_path = _resolve_template_path(env, block_type)
template_path = _resolve_template_path(env, block_type, block.get("_variant", "default") if isinstance(block, dict) else "default")
if template_path:
try:
@@ -352,7 +401,7 @@ def render_slide(layout: dict[str, Any]) -> str:
block_type = block["type"]
block_data = block.get("data", {})
template_path = _resolve_template_path(env, block_type)
template_path = _resolve_template_path(env, block_type, block.get("_variant", "default") if isinstance(block, dict) else "default")
if template_path:
try:
@@ -403,10 +452,154 @@ def render_slide(layout: dict[str, Any]) -> str:
return html
def render_standalone_block(block_type: str, data: dict[str, Any]) -> str:
def render_block_in_container(
block_type: str,
data: dict[str, Any],
container_height_px: int,
container_width_px: int,
font_size_px: float = 15.2,
padding_px: int = 20,
) -> str:
"""Phase P: 단일 블록을 컨테이너 안에서 렌더링한다.
컨테이너 크기에 맞게 CSS가 적용된 완전한 HTML을 반환.
Selenium으로 스크린샷 캡처 + 높이 측정에 사용.
"""
block_html = render_standalone_block(block_type, data)
tokens_css = (STATIC_DIR / "tokens.css").read_text(encoding="utf-8")
base_css = (STATIC_DIR / "base.css").read_text(encoding="utf-8")
base_css = base_css.replace("@import url('./tokens.css');", "")
return f"""<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<style>
{tokens_css}
{base_css}
.candidate-container {{
width: {container_width_px}px;
height: {container_height_px}px;
overflow: visible;
font-size: {font_size_px}px;
--font-body: {font_size_px / 16:.3f}rem;
--spacing-inner: {padding_px}px;
padding: {padding_px}px;
font-family: 'Pretendard Variable', 'Pretendard', sans-serif;
line-height: 1.7;
word-break: keep-all;
}}
</style>
</head>
<body>
<div class="candidate-container">
{block_html}
</div>
</body>
</html>"""
def render_slide_from_html(
generated: dict[str, str],
analysis: dict[str, Any],
preset: dict[str, Any],
) -> str:
"""Phase R': AI가 생성한 HTML 조각을 슬라이드 프레임에 삽입하여 완성 HTML 반환.
블록 템플릿을 렌더링하지 않는다. AI가 생성한 body/sidebar/footer HTML을 직접 삽입.
Args:
generated: {"body_html": "...", "sidebar_html": "...", "footer_html": "..."}
analysis: 1단계 분석 결과 (title)
preset: 프리셋 정보 (grid_areas, grid_columns, grid_rows)
"""
tokens_css = ""
tokens_path = Path(__file__).parent.parent / "static" / "tokens.css"
if tokens_path.exists():
tokens_css = tokens_path.read_text(encoding="utf-8")
base_css = ""
base_path = Path(__file__).parent.parent / "static" / "base.css"
if base_path.exists():
# @import 제거 (inline으로 포함하므로)
raw = base_path.read_text(encoding="utf-8")
base_css = "\n".join(
line for line in raw.split("\n")
if not line.strip().startswith("@import")
)
title = analysis.get("title", "슬라이드")
grid_areas = preset.get("grid_areas", "'header header' 'body sidebar' 'footer footer'")
grid_columns = preset.get("grid_columns", "65fr 35fr")
grid_rows = preset.get("grid_rows", "auto 1fr auto")
body_html = generated.get("body_html", "")
sidebar_html = generated.get("sidebar_html", "")
footer_html = generated.get("footer_html", "")
html = f"""<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>{title}</title>
<style>
{tokens_css}
{base_css}
.slide-1 {{
grid-template-areas: {grid_areas};
grid-template-columns: {grid_columns};
grid-template-rows: {grid_rows};
}}
.slide-1 .area-body {{ grid-area: body; }}
.slide-1 .area-sidebar {{ grid-area: sidebar; }}
.slide-1 .area-footer {{ grid-area: footer; }}
.slide + .slide {{ margin-top: 40px; }}
@media print {{
.slide {{ page-break-after: always; }}
.slide + .slide {{ margin-top: 0; }}
}}
</style>
</head>
<body>
<div class="slide slide-1">
<div class="slide-title" style="grid-area: header;">{title}</div>
<div class="area-body" style="overflow:hidden;">
{body_html}
</div>
<div class="area-sidebar" style="overflow:hidden;">
{sidebar_html}
</div>
<div class="area-footer" style="overflow:hidden;">
{footer_html}
</div>
</div>
<script>
window.onbeforeprint = function() {{
document.querySelectorAll('details').forEach(function(d) {{ d.open = true; }});
}};
window.onafterprint = function() {{
document.querySelectorAll('details').forEach(function(d) {{ d.open = false; }});
}};
</script>
</body>
</html>"""
logger.info(f"[R'] 슬라이드 렌더링 완료: {title}")
return html
def render_standalone_block(block_type: str, data: dict[str, Any], variant: str = "default") -> str:
"""단일 블록을 독립 HTML로 렌더링 (테스트/미리보기용)."""
env = create_jinja_env()
template_path = _resolve_template_path(env, block_type)
template_path = _resolve_template_path(env, block_type, variant)
if not template_path:
return f"<div>블록 미발견: {block_type}</div>"
template = env.get_template(template_path)

View File

@@ -1,11 +1,13 @@
"""Phase O: 컨테이너 기반 공간 할당 시스템.
"""Phase O + Phase Q: 컨테이너 기반 공간 할당 + 글자수 예산 + 글루 모델.
Kei 비중 → 컨테이너 px 확정 → 블록 제약 계산 → 편집자 스펙 생성.
LLM 추정이 아닌 결정론적 계산.
주요 함수:
- calculate_container_specs(): Kei 비중 → 역할별 ContainerSpec
- finalize_block_specs(): 컨테이너 크기 → 블록별 내부 스펙
- calculate_container_specs(): Kei 비중 → 역할별 ContainerSpec (Phase O)
- finalize_block_specs(): 컨테이너 크기 → 블록별 내부 스펙 (Phase O)
- calculate_char_budget(): 블록+컨테이너 → 글자수 예산 사전 계산 (Phase Q-3)
- apply_glue_compression(): overflow 시 수학적 간격 축소 (Phase Q-7)
- calculate_trim_chars(): 초과 px → 삭제 글자 수
"""
from __future__ import annotations
@@ -310,3 +312,247 @@ def calculate_trim_chars(
lines_to_remove = math.ceil(excess_px / line_height_px)
chars_per_line = int(container_width_px / avg_char_width_px)
return max(lines_to_remove * chars_per_line, 10)
# ──────────────────────────────────────
# Phase Q-3: 글자수 예산 계산
# ──────────────────────────────────────
# 블록 유형별 구조적 오버헤드 (제목, 패딩, 간격 등 — px 단위)
# Phase Q 2차 테스트 기반 실측 보정: 실제 CSS padding/margin 기반
_BLOCK_STRUCTURAL_OVERHEAD: dict[str, int] = {
"card-numbered": 40, # 패딩 12*2=24 + gap 10 + border 2 + 여유
"card-icon-desc": 50, # 아이콘 40 + 패딩 + gap
"card-step-vertical": 50, # 마커 30 + 패딩 + gap
"dark-bullet-list": 52, # 패딩 20*2=40 + 제목 줄 12
"comparison-2col": 60, # 헤더*2 + 구분선 + 패딩
"compare-3col-badge": 60, # 헤더 행 40 + 배지 + 패딩
"compare-2col-split": 60, # 헤더 행 40 + 패딩
"table-simple-striped": 50, # 헤더 행 35 + 패딩
"banner-gradient": 36, # 패딩 16*2=32 + 여유
"callout-solution": 50, # 아이콘 + 제목 30 + 패딩 20
"callout-warning": 50, # 아이콘 + 제목 30 + 패딩 20
"quote-big-mark": 50, # 따옴표 장식 + 패딩 20*2
"quote-question": 76, # 패딩 28*2=56 + desc margin 10 + 여유 10 (실측 기반)
"compare-pill-pair": 52, # 외곽 패딩 6*2 + 내부 패딩 18*2 + 여유
"venn-diagram": 60, # SVG 구조 + 패딩
"process-horizontal": 50, # 화살표 + 번호 36 + 패딩
"flow-arrow-horizontal": 30, # 캡슐 + 화살표 + 패딩
"keyword-circle-row": 60, # 원형 + 라벨 + 패딩
}
# 같은 컨테이너 내 블록 간 gap (px)
_CONTAINER_BLOCK_GAP = 8
def calculate_char_budget(
block_type: str,
container_spec: ContainerSpec,
block_def: dict | None = None,
) -> dict:
"""블록이 컨테이너에서 수용 가능한 최대 글자수를 사전 계산한다.
Phase Q 핵심: 이 예산이 AI 콘텐츠 생성의 하드 제약.
Args:
block_type: 블록 ID (예: "venn-diagram")
container_spec: 이 topic이 속한 컨테이너
block_def: catalog.yaml의 블록 정의 (None이면 기본값 사용)
Returns:
{
"total_chars": int, # 전체 글자수 예산
"max_items": int, # 최대 항목 수
"chars_per_item": int, # 항목당 최대 글자수
"font_size_px": float, # 적용 폰트 크기
"available_lines": int, # 가용 줄 수
}
"""
topic_count = max(1, len(container_spec.topic_ids))
# 같은 컨테이너 내 블록 간 gap 차감
total_gap = _CONTAINER_BLOCK_GAP * max(0, topic_count - 1)
available_container_height = max(40, container_spec.height_px - total_gap)
per_topic_px = available_container_height // topic_count
# 폰트 크기 결정
font_size, padding, line_h = _determine_typography(per_topic_px)
# 구조적 오버헤드
structural = _BLOCK_STRUCTURAL_OVERHEAD.get(block_type, 20)
content_height = max(10, per_topic_px - structural)
# 줄 수 계산
line_height_px = font_size * line_h
available_lines = max(1, int(content_height / line_height_px))
# 한국어 줄당 글자수 (폰트 크기 기반)
usable_width = container_spec.width_px * 0.85 # 패딩 제외
chars_per_line = max(5, int(usable_width / font_size))
# 항목 수 제한 (블록 정의 참조)
max_items_by_space = max(1, available_lines // 2) # 항목당 최소 2줄
catalog_max = 10
catalog_min = 1
if block_def:
catalog_max = block_def.get("max_items", 10)
catalog_min = block_def.get("min_items", 1)
max_items = min(max_items_by_space, catalog_max)
max_items = max(max_items, catalog_min)
total_chars = available_lines * chars_per_line
chars_per_item = total_chars // max(1, max_items)
budget = {
"total_chars": max(20, total_chars),
"max_items": max_items,
"chars_per_item": max(10, chars_per_item),
"font_size_px": font_size,
"available_lines": available_lines,
}
logger.info(
f"[Q-3] 예산: {block_type}{budget['total_chars']}자, "
f"{budget['max_items']}항목, {budget['font_size_px']}px"
)
return budget
def calculate_budgets_for_candidates(
candidates: list[dict],
container_spec: ContainerSpec,
) -> dict[str, dict]:
"""후보 블록 리스트의 각 블록에 대해 글자수 예산을 계산한다.
Returns:
{"block_id": {"total_chars": ..., "max_items": ..., ...}, ...}
"""
budgets = {}
for block in candidates:
block_id = block.get("id", "")
budgets[block_id] = calculate_char_budget(
block_id, container_spec, block_def=block
)
return budgets
# ──────────────────────────────────────
# Phase Q-7: LaTeX 글루 모델 (overflow 수학적 조정)
# ──────────────────────────────────────
@dataclass
class GlueSpec:
"""LaTeX 글루 모델: 유연한 간격 정의.
natural: 기본 간격 (px)
stretch: 늘어날 수 있는 양 (px)
shrink: 줄어들 수 있는 양 (px)
"""
natural: float
stretch: float
shrink: float
# 간격 유형별 글루 설정
SPACING_GLUE: dict[str, GlueSpec] = {
"block_gap": GlueSpec(natural=20, stretch=4, shrink=12),
"inner_gap": GlueSpec(natural=16, stretch=4, shrink=8),
"title_gap": GlueSpec(natural=8, stretch=2, shrink=4),
"container_padding": GlueSpec(natural=16, stretch=0, shrink=8),
}
# 폰트 크기 축소 단계 (이진 탐색용)
FONT_SIZE_STEPS = [15.2, 14.0, 13.0, 12.0, 11.0, 10.0, 9.0, 8.0]
def calculate_glue_absorption(block_count: int) -> float:
"""글루 모델로 흡수 가능한 최대 px를 계산한다.
Args:
block_count: 컨테이너 내 블록 수
Returns:
흡수 가능한 총 shrink px
"""
total_shrink = 0.0
# 블록 간 간격
total_shrink += SPACING_GLUE["block_gap"].shrink * max(0, block_count - 1)
# 각 블록 내부 간격
total_shrink += SPACING_GLUE["inner_gap"].shrink * block_count
# 제목 간격
total_shrink += SPACING_GLUE["title_gap"].shrink * block_count
# 컨테이너 패딩 (상하)
total_shrink += SPACING_GLUE["container_padding"].shrink * 2
return total_shrink
def compute_glue_css_overrides(
excess_px: float,
block_count: int,
) -> dict[str, str]:
"""overflow excess를 흡수하기 위한 CSS 변수 오버라이드를 계산한다.
Returns:
{"--spacing-block": "8px", "--spacing-inner": "8px", ...} 또는
None (글루만으로 흡수 불가)
"""
max_absorption = calculate_glue_absorption(block_count)
if excess_px <= 0:
return {}
if excess_px > max_absorption:
# 글루만으로 부족 — 부분 축소 적용 후 나머지는 폰트 축소 필요
ratio = 1.0
else:
ratio = excess_px / max_absorption
overrides = {}
# 비율에 따라 각 간격 축소
block_gap = SPACING_GLUE["block_gap"]
new_block_gap = block_gap.natural - block_gap.shrink * ratio
overrides["--spacing-block"] = f"{new_block_gap:.0f}px"
inner_gap = SPACING_GLUE["inner_gap"]
new_inner_gap = inner_gap.natural - inner_gap.shrink * ratio
overrides["--spacing-inner"] = f"{new_inner_gap:.0f}px"
padding = SPACING_GLUE["container_padding"]
new_padding = padding.natural - padding.shrink * ratio
overrides["--container-padding"] = f"{new_padding:.0f}px"
logger.info(
f"[Q-7] 글루 압축: excess={excess_px:.0f}px, "
f"absorption={max_absorption:.0f}px, ratio={ratio:.2f}"
)
return overrides
def find_fitting_font_size(
current_font_px: float,
excess_after_glue_px: float,
available_lines: int,
chars_per_line: int,
) -> float | None:
"""글루 압축 후에도 남은 overflow를 폰트 축소로 해결할 수 있는지 확인.
Returns:
적합한 폰트 크기 (px) 또는 None (불가능)
"""
for font_size in FONT_SIZE_STEPS:
if font_size >= current_font_px:
continue # 현재보다 같거나 큰 크기는 스킵
# 이 폰트에서의 줄 높이
line_height_px = font_size * 1.6 # 한국어 기본
height_saved = (current_font_px * 1.6 - line_height_px) * available_lines
if height_saved >= excess_after_glue_px:
logger.info(
f"[Q-7] 폰트 축소: {current_font_px}px → {font_size}px "
f"({height_saved:.0f}px 확보)"
)
return font_size
return None # 8px에서도 안 맞으면 AI 텍스트 압축 필요