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:
267
src/block_selector.py
Normal file
267
src/block_selector.py
Normal 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
635
src/content_verifier.py
Normal 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("&", "&").replace("<", "<").replace(">", ">")
|
||||
text = text.replace(" ", " ").replace("'", "'").replace(""", '"')
|
||||
# 개조식 어미 → 서술형 (비교 기준 통일)
|
||||
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
177
src/design_tokens.py
Normal 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
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
|
||||
79
src/html_validator.py
Normal file
79
src/html_validator.py
Normal 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]})"
|
||||
)
|
||||
@@ -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": []}
|
||||
|
||||
343
src/pipeline.py
343
src/pipeline.py
@@ -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
|
||||
|
||||
|
||||
211
src/renderer.py
211
src/renderer.py
@@ -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)
|
||||
|
||||
@@ -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 텍스트 압축 필요
|
||||
|
||||
Reference in New Issue
Block a user