블록 선택 방식(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>
268 lines
10 KiB
Python
268 lines
10 KiB
Python
"""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)
|